Skip to main content

stateset_db/sqlite/
general_ledger.rs

1//! SQLite implementation of General Ledger repository
2
3use crate::sqlite::{map_db_error, parse_uuid, with_immediate_transaction};
4use chrono::{NaiveDate, Utc};
5use r2d2::Pool;
6use r2d2_sqlite::SqliteConnectionManager;
7use rusqlite::{params, types::Type};
8use rust_decimal::Decimal;
9use stateset_core::{
10    AccountStatus, AccountSubType, AccountType, AutoPostingConfig, BalanceSheet, BalanceSheetLine,
11    BalanceSide, BatchResult, CreateAutoPostingConfig, CreateGlAccount, CreateGlPeriod,
12    CreateJournalEntry, Currency, FX_REVALUATION_REFERENCE, GeneralLedgerRepository, GlAccount,
13    GlAccountFilter, GlPeriod, GlPeriodFilter, IncomeStatement, IncomeStatementLine, InvoiceId,
14    JournalEntry, JournalEntryFilter, JournalEntryLine, JournalEntrySource, JournalEntryStatus,
15    JournalEntryType, PeriodStatus, Result, RevaluationLine, RevaluationResult, TrialBalance,
16    TrialBalanceLine, UpdateGlAccount, create_default_chart_of_accounts,
17    generate_journal_entry_number,
18};
19use uuid::Uuid;
20
21#[derive(Debug)]
22pub struct SqliteGeneralLedgerRepository {
23    pool: Pool<SqliteConnectionManager>,
24}
25
26fn parse_required<T>(value: String, column: usize) -> rusqlite::Result<T>
27where
28    T: std::str::FromStr,
29    T::Err: std::fmt::Display,
30{
31    value.parse::<T>().map_err(|err: T::Err| {
32        rusqlite::Error::FromSqlConversionFailure(
33            column,
34            Type::Text,
35            Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string())),
36        )
37    })
38}
39
40fn parse_optional<T>(value: Option<String>, column: usize) -> rusqlite::Result<Option<T>>
41where
42    T: std::str::FromStr,
43    T::Err: std::fmt::Display,
44{
45    match value {
46        Some(value) => parse_required(value, column).map(Some),
47        None => Ok(None),
48    }
49}
50
51fn parse_decimal_required(value: String, column: usize) -> rusqlite::Result<Decimal> {
52    parse_required(value, column)
53}
54
55impl SqliteGeneralLedgerRepository {
56    #[must_use]
57    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
58        Self { pool }
59    }
60
61    fn map_account_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<GlAccount> {
62        Ok(GlAccount {
63            id: parse_required(row.get::<_, String>(0)?, 0)?,
64            account_number: row.get(1)?,
65            name: row.get(2)?,
66            description: row.get(3)?,
67            account_type: parse_required(row.get::<_, String>(4)?, 4)?,
68            account_sub_type: parse_optional(row.get::<_, Option<String>>(5)?, 5)?,
69            parent_account_id: parse_optional(row.get::<_, Option<String>>(6)?, 6)?,
70            is_header: row.get::<_, i32>(7)? != 0,
71            is_posting: row.get::<_, i32>(8)? != 0,
72            normal_balance: parse_required(row.get::<_, String>(9)?, 9)?,
73            currency: row.get(10)?,
74            status: parse_required(row.get::<_, String>(11)?, 11)?,
75            current_balance: parse_decimal_required(row.get::<_, String>(12)?, 12)?,
76            created_at: parse_required(row.get::<_, String>(13)?, 13)?,
77            updated_at: parse_required(row.get::<_, String>(14)?, 14)?,
78        })
79    }
80
81    fn map_period_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<GlPeriod> {
82        Ok(GlPeriod {
83            id: parse_required(row.get::<_, String>(0)?, 0)?,
84            period_name: row.get(1)?,
85            fiscal_year: row.get(2)?,
86            period_number: row.get(3)?,
87            start_date: parse_required(row.get::<_, String>(4)?, 4)?,
88            end_date: parse_required(row.get::<_, String>(5)?, 5)?,
89            status: parse_required(row.get::<_, String>(6)?, 6)?,
90            closed_at: parse_optional(row.get::<_, Option<String>>(7)?, 7)?,
91            closed_by: row.get(8)?,
92            locked_at: parse_optional(row.get::<_, Option<String>>(9)?, 9)?,
93            locked_by: row.get(10)?,
94            created_at: parse_required(row.get::<_, String>(11)?, 11)?,
95            updated_at: parse_required(row.get::<_, String>(12)?, 12)?,
96        })
97    }
98
99    fn map_journal_entry_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<JournalEntry> {
100        Ok(JournalEntry {
101            id: parse_required(row.get::<_, String>(0)?, 0)?,
102            entry_number: row.get(1)?,
103            entry_date: parse_required(row.get::<_, String>(2)?, 2)?,
104            period_id: parse_required(row.get::<_, String>(3)?, 3)?,
105            entry_type: parse_required(row.get::<_, String>(4)?, 4)?,
106            source: parse_required(row.get::<_, String>(5)?, 5)?,
107            source_document_type: row.get(6)?,
108            source_document_id: parse_optional(row.get::<_, Option<String>>(7)?, 7)?,
109            description: row.get(8)?,
110            total_debits: parse_decimal_required(row.get::<_, String>(9)?, 9)?,
111            total_credits: parse_decimal_required(row.get::<_, String>(10)?, 10)?,
112            is_balanced: row.get::<_, i32>(11)? != 0,
113            status: parse_required(row.get::<_, String>(12)?, 12)?,
114            posted_at: parse_optional(row.get::<_, Option<String>>(13)?, 13)?,
115            posted_by: row.get(14)?,
116            reversed_entry_id: parse_optional(row.get::<_, Option<String>>(15)?, 15)?,
117            reversing_entry_id: parse_optional(row.get::<_, Option<String>>(16)?, 16)?,
118            lines: Vec::new(),
119            created_at: parse_required(row.get::<_, String>(17)?, 17)?,
120            updated_at: parse_required(row.get::<_, String>(18)?, 18)?,
121        })
122    }
123
124    fn map_journal_entry_line_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<JournalEntryLine> {
125        Ok(JournalEntryLine {
126            id: parse_required(row.get::<_, String>(0)?, 0)?,
127            journal_entry_id: parse_required(row.get::<_, String>(1)?, 1)?,
128            line_number: row.get(2)?,
129            account_id: parse_required(row.get::<_, String>(3)?, 3)?,
130            account_number: row.get(4)?,
131            account_name: row.get(5)?,
132            description: row.get(6)?,
133            debit_amount: parse_decimal_required(row.get::<_, String>(7)?, 7)?,
134            credit_amount: parse_decimal_required(row.get::<_, String>(8)?, 8)?,
135            currency: row.get(9)?,
136            reference_type: row.get(10)?,
137            reference_id: parse_optional(row.get::<_, Option<String>>(11)?, 11)?,
138            created_at: parse_required(row.get::<_, String>(12)?, 12)?,
139        })
140    }
141
142    fn map_auto_posting_config_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AutoPostingConfig> {
143        Ok(AutoPostingConfig {
144            id: parse_required(row.get::<_, String>(0)?, 0)?,
145            config_name: row.get(1)?,
146            cash_account_id: parse_required(row.get::<_, String>(2)?, 2)?,
147            accounts_receivable_account_id: parse_required(row.get::<_, String>(3)?, 3)?,
148            inventory_account_id: parse_required(row.get::<_, String>(4)?, 4)?,
149            accounts_payable_account_id: parse_required(row.get::<_, String>(5)?, 5)?,
150            unearned_revenue_account_id: parse_optional(row.get::<_, Option<String>>(6)?, 6)?,
151            sales_revenue_account_id: parse_required(row.get::<_, String>(7)?, 7)?,
152            shipping_revenue_account_id: parse_optional(row.get::<_, Option<String>>(8)?, 8)?,
153            cogs_account_id: parse_required(row.get::<_, String>(9)?, 9)?,
154            bad_debt_expense_account_id: parse_optional(row.get::<_, Option<String>>(10)?, 10)?,
155            fx_gain_loss_account_id: parse_optional(row.get::<_, Option<String>>(11)?, 11)?,
156            auto_post_depreciation: row.get::<_, i32>(12)? != 0,
157            auto_post_revenue_recognition: row.get::<_, i32>(13)? != 0,
158            is_active: row.get::<_, i32>(14)? != 0,
159            created_at: parse_required(row.get::<_, String>(15)?, 15)?,
160            updated_at: parse_required(row.get::<_, String>(16)?, 16)?,
161        })
162    }
163
164    fn update_account_balance_with_conn(
165        conn: &rusqlite::Connection,
166        account_id: Uuid,
167        debit: Decimal,
168        credit: Decimal,
169    ) -> Result<()> {
170        // Get account to determine normal balance
171        let account: GlAccount = conn
172            .query_row(
173                "SELECT id, account_number, name, description, account_type, account_sub_type,
174                    parent_account_id, is_header, is_posting, normal_balance, currency,
175                    status, current_balance, created_at, updated_at
176             FROM gl_accounts WHERE id = ?1",
177                params![account_id.to_string()],
178                Self::map_account_row,
179            )
180            .map_err(map_db_error)?;
181
182        let balance_change = account.balance_effect(debit, credit);
183        let new_balance = account.current_balance + balance_change;
184
185        conn.execute(
186            "UPDATE gl_accounts SET current_balance = ?1 WHERE id = ?2",
187            params![new_balance.to_string(), account_id.to_string()],
188        )
189        .map_err(map_db_error)?;
190
191        Ok(())
192    }
193
194    fn load_journal_entry_lines_with_conn(
195        conn: &rusqlite::Connection,
196        journal_entry_id: Uuid,
197    ) -> rusqlite::Result<Vec<JournalEntryLine>> {
198        let mut stmt = conn.prepare(
199            "SELECT id, journal_entry_id, line_number, account_id, account_number,
200                    account_name, description, debit_amount, credit_amount, currency,
201                    reference_type, reference_id, created_at
202             FROM gl_journal_entry_lines WHERE journal_entry_id = ?1 ORDER BY line_number",
203        )?;
204        let rows = stmt
205            .query_map(params![journal_entry_id.to_string()], Self::map_journal_entry_line_row)?;
206        rows.collect()
207    }
208
209    fn load_journal_entry_with_conn(
210        conn: &rusqlite::Connection,
211        id: Uuid,
212    ) -> rusqlite::Result<JournalEntry> {
213        let mut entry = conn.query_row(
214            "SELECT id, entry_number, entry_date, period_id, entry_type, source,
215                    source_document_type, source_document_id, description, total_debits,
216                    total_credits, is_balanced, status, posted_at, posted_by,
217                    reversed_entry_id, reversing_entry_id, created_at, updated_at
218             FROM gl_journal_entries WHERE id = ?1",
219            params![id.to_string()],
220            Self::map_journal_entry_row,
221        )?;
222        entry.lines = Self::load_journal_entry_lines_with_conn(conn, id)?;
223        Ok(entry)
224    }
225
226    /// Look up the exchange rate converting one `from` unit into `to` units,
227    /// falling back to the inverse of the reverse pair when only that is set.
228    fn lookup_rate_with_conn(
229        conn: &rusqlite::Connection,
230        from: &str,
231        to: &str,
232    ) -> Result<Option<Decimal>> {
233        let direct = conn.query_row(
234            "SELECT rate FROM exchange_rates WHERE base_currency = ?1 AND quote_currency = ?2",
235            params![from, to],
236            |row| row.get::<_, String>(0),
237        );
238        match direct {
239            Ok(rate) => Ok(Some(parse_decimal_required(rate, 0).map_err(map_db_error)?)),
240            Err(rusqlite::Error::QueryReturnedNoRows) => {
241                let inverse = conn.query_row(
242                    "SELECT rate FROM exchange_rates
243                     WHERE base_currency = ?1 AND quote_currency = ?2",
244                    params![to, from],
245                    |row| row.get::<_, String>(0),
246                );
247                match inverse {
248                    Ok(rate) => {
249                        let rate = parse_decimal_required(rate, 0).map_err(map_db_error)?;
250                        if rate.is_zero() { Ok(None) } else { Ok(Some(Decimal::ONE / rate)) }
251                    }
252                    Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
253                    Err(e) => Err(map_db_error(e)),
254                }
255            }
256            Err(e) => Err(map_db_error(e)),
257        }
258    }
259
260    /// Resolve the account receiving unrealized FX gains/losses: the
261    /// configured `fx_gain_loss_account_id`, else the first active posting
262    /// account with an Other Expense / Other Revenue sub-type.
263    fn resolve_fx_gain_loss_account(&self) -> Result<Uuid> {
264        if let Some(id) =
265            self.get_auto_posting_config()?.and_then(|config| config.fx_gain_loss_account_id)
266        {
267            return Ok(id);
268        }
269        for sub_type in [AccountSubType::OtherExpense, AccountSubType::OtherRevenue] {
270            let fallback = self
271                .list_accounts(GlAccountFilter {
272                    account_sub_type: Some(sub_type),
273                    status: Some(AccountStatus::Active),
274                    is_posting: Some(true),
275                    limit: Some(1),
276                    ..Default::default()
277                })?
278                .into_iter()
279                .next();
280            if let Some(account) = fallback {
281                return Ok(account.id);
282            }
283        }
284        Err(stateset_core::CommerceError::ValidationError(
285            "No FX gain/loss account configured for revaluation".to_string(),
286        ))
287    }
288}
289
290impl GeneralLedgerRepository for SqliteGeneralLedgerRepository {
291    // ========================================================================
292    // Chart of Accounts
293    // ========================================================================
294
295    fn create_account(&self, input: CreateGlAccount) -> Result<GlAccount> {
296        let id = Uuid::new_v4();
297        let now = Utc::now();
298        let normal_balance = input.account_type.normal_balance();
299
300        {
301            let conn = self
302                .pool
303                .get()
304                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
305            conn.execute(
306                "INSERT INTO gl_accounts (id, account_number, name, description, account_type,
307                 account_sub_type, parent_account_id, is_header, is_posting, normal_balance,
308                 currency, status, current_balance, created_at, updated_at)
309                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
310                params![
311                    id.to_string(),
312                    input.account_number,
313                    input.name,
314                    input.description,
315                    input.account_type.to_string(),
316                    input.account_sub_type.map(|s| s.to_string()),
317                    input.parent_account_id.map(|id| id.to_string()),
318                    i32::from(input.is_header.unwrap_or(false)),
319                    i32::from(input.is_posting.unwrap_or(true)),
320                    normal_balance.to_string(),
321                    input.currency.unwrap_or_default(),
322                    AccountStatus::Active.to_string(),
323                    "0",
324                    now.to_rfc3339(),
325                    now.to_rfc3339(),
326                ],
327            )
328            .map_err(map_db_error)?;
329        }
330
331        self.get_account(id)?.ok_or(stateset_core::CommerceError::NotFound)
332    }
333
334    fn get_account(&self, id: Uuid) -> Result<Option<GlAccount>> {
335        let conn = self
336            .pool
337            .get()
338            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
339
340        match conn.query_row(
341            "SELECT id, account_number, name, description, account_type, account_sub_type,
342                    parent_account_id, is_header, is_posting, normal_balance, currency,
343                    status, current_balance, created_at, updated_at
344             FROM gl_accounts WHERE id = ?1",
345            params![id.to_string()],
346            Self::map_account_row,
347        ) {
348            Ok(account) => Ok(Some(account)),
349            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
350            Err(e) => Err(map_db_error(e)),
351        }
352    }
353
354    fn get_account_by_number(&self, account_number: &str) -> Result<Option<GlAccount>> {
355        let conn = self
356            .pool
357            .get()
358            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
359
360        match conn.query_row(
361            "SELECT id, account_number, name, description, account_type, account_sub_type,
362                    parent_account_id, is_header, is_posting, normal_balance, currency,
363                    status, current_balance, created_at, updated_at
364             FROM gl_accounts WHERE account_number = ?1",
365            params![account_number],
366            Self::map_account_row,
367        ) {
368            Ok(account) => Ok(Some(account)),
369            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
370            Err(e) => Err(map_db_error(e)),
371        }
372    }
373
374    fn update_account(&self, id: Uuid, input: UpdateGlAccount) -> Result<GlAccount> {
375        let conn = self
376            .pool
377            .get()
378            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
379
380        let mut updates = Vec::new();
381        let mut values: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
382
383        if let Some(name) = input.name {
384            updates.push("name = ?");
385            values.push(Box::new(name));
386        }
387        if let Some(description) = input.description {
388            updates.push("description = ?");
389            values.push(Box::new(description));
390        }
391        if let Some(parent_id) = input.parent_account_id {
392            updates.push("parent_account_id = ?");
393            values.push(Box::new(parent_id.to_string()));
394        }
395        if let Some(status) = input.status {
396            updates.push("status = ?");
397            values.push(Box::new(status.to_string()));
398        }
399
400        if !updates.is_empty() {
401            values.push(Box::new(id.to_string()));
402            let sql = format!("UPDATE gl_accounts SET {} WHERE id = ?", updates.join(", "));
403            let params: Vec<&dyn rusqlite::ToSql> =
404                values.iter().map(std::convert::AsRef::as_ref).collect();
405            conn.execute(&sql, params.as_slice()).map_err(map_db_error)?;
406        }
407
408        self.get_account(id)?.ok_or(stateset_core::CommerceError::NotFound)
409    }
410
411    fn list_accounts(&self, filter: GlAccountFilter) -> Result<Vec<GlAccount>> {
412        let conn = self
413            .pool
414            .get()
415            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
416
417        let mut sql = String::from(
418            "SELECT id, account_number, name, description, account_type, account_sub_type,
419                    parent_account_id, is_header, is_posting, normal_balance, currency,
420                    status, current_balance, created_at, updated_at
421             FROM gl_accounts WHERE 1=1",
422        );
423        let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
424
425        if let Some(account_type) = filter.account_type {
426            sql.push_str(" AND account_type = ?");
427            params.push(Box::new(account_type.to_string()));
428        }
429        if let Some(sub_type) = filter.account_sub_type {
430            sql.push_str(" AND account_sub_type = ?");
431            params.push(Box::new(sub_type.to_string()));
432        }
433        if let Some(parent_id) = filter.parent_account_id {
434            sql.push_str(" AND parent_account_id = ?");
435            params.push(Box::new(parent_id.to_string()));
436        }
437        if let Some(status) = filter.status {
438            sql.push_str(" AND status = ?");
439            params.push(Box::new(status.to_string()));
440        }
441        if let Some(is_posting) = filter.is_posting {
442            sql.push_str(" AND is_posting = ?");
443            params.push(Box::new(i32::from(is_posting)));
444        }
445        if let Some(is_header) = filter.is_header {
446            sql.push_str(" AND is_header = ?");
447            params.push(Box::new(i32::from(is_header)));
448        }
449        if let Some(search) = filter.search {
450            sql.push_str(" AND (name LIKE ? OR account_number LIKE ?)");
451            let search_term = format!("%{search}%");
452            params.push(Box::new(search_term.clone()));
453            params.push(Box::new(search_term));
454        }
455
456        sql.push_str(" ORDER BY account_number");
457
458        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
459
460        let params_refs: Vec<&dyn rusqlite::ToSql> =
461            params.iter().map(std::convert::AsRef::as_ref).collect();
462        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
463        let rows =
464            stmt.query_map(params_refs.as_slice(), Self::map_account_row).map_err(map_db_error)?;
465
466        let mut accounts = Vec::new();
467        for row in rows {
468            accounts.push(row.map_err(map_db_error)?);
469        }
470        Ok(accounts)
471    }
472
473    fn get_account_hierarchy(&self) -> Result<Vec<GlAccount>> {
474        self.list_accounts(GlAccountFilter::default())
475    }
476
477    fn delete_account(&self, id: Uuid) -> Result<()> {
478        let conn = self
479            .pool
480            .get()
481            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
482
483        // Check if account has transactions
484        let count: i32 = conn
485            .query_row(
486                "SELECT COUNT(*) FROM gl_journal_entry_lines WHERE account_id = ?1",
487                params![id.to_string()],
488                |row| row.get(0),
489            )
490            .map_err(map_db_error)?;
491
492        if count > 0 {
493            return Err(stateset_core::CommerceError::ValidationError(
494                "Cannot delete account with existing transactions".to_string(),
495            ));
496        }
497
498        conn.execute("DELETE FROM gl_accounts WHERE id = ?1", params![id.to_string()])
499            .map_err(map_db_error)?;
500
501        Ok(())
502    }
503
504    fn initialize_chart_of_accounts(&self) -> Result<Vec<GlAccount>> {
505        let defaults = create_default_chart_of_accounts();
506        let mut accounts = Vec::new();
507
508        for input in defaults {
509            // Check if account already exists
510            if self.get_account_by_number(&input.account_number)?.is_none() {
511                accounts.push(self.create_account(input)?);
512            }
513        }
514
515        Ok(accounts)
516    }
517
518    // ========================================================================
519    // GL Periods
520    // ========================================================================
521
522    fn create_period(&self, input: CreateGlPeriod) -> Result<GlPeriod> {
523        let conn = self
524            .pool
525            .get()
526            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
527        let id = Uuid::new_v4();
528        let now = Utc::now();
529
530        conn.execute(
531            "INSERT INTO gl_periods (id, period_name, fiscal_year, period_number,
532             start_date, end_date, status, created_at, updated_at)
533             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
534            params![
535                id.to_string(),
536                input.period_name,
537                input.fiscal_year,
538                input.period_number,
539                input.start_date.to_string(),
540                input.end_date.to_string(),
541                PeriodStatus::Future.to_string(),
542                now.to_rfc3339(),
543                now.to_rfc3339(),
544            ],
545        )
546        .map_err(map_db_error)?;
547
548        self.get_period(id)?.ok_or(stateset_core::CommerceError::NotFound)
549    }
550
551    fn get_period(&self, id: Uuid) -> Result<Option<GlPeriod>> {
552        let conn = self
553            .pool
554            .get()
555            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
556
557        match conn.query_row(
558            "SELECT id, period_name, fiscal_year, period_number, start_date, end_date,
559                    status, closed_at, closed_by, locked_at, locked_by, created_at, updated_at
560             FROM gl_periods WHERE id = ?1",
561            params![id.to_string()],
562            Self::map_period_row,
563        ) {
564            Ok(period) => Ok(Some(period)),
565            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
566            Err(e) => Err(map_db_error(e)),
567        }
568    }
569
570    fn get_current_period(&self) -> Result<Option<GlPeriod>> {
571        let conn = self
572            .pool
573            .get()
574            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
575
576        match conn.query_row(
577            "SELECT id, period_name, fiscal_year, period_number, start_date, end_date,
578                    status, closed_at, closed_by, locked_at, locked_by, created_at, updated_at
579             FROM gl_periods WHERE status = 'open' ORDER BY start_date DESC LIMIT 1",
580            [],
581            Self::map_period_row,
582        ) {
583            Ok(period) => Ok(Some(period)),
584            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
585            Err(e) => Err(map_db_error(e)),
586        }
587    }
588
589    fn get_period_for_date(&self, date: NaiveDate) -> Result<Option<GlPeriod>> {
590        let conn = self
591            .pool
592            .get()
593            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
594
595        match conn.query_row(
596            "SELECT id, period_name, fiscal_year, period_number, start_date, end_date,
597                    status, closed_at, closed_by, locked_at, locked_by, created_at, updated_at
598             FROM gl_periods WHERE start_date <= ?1 AND end_date >= ?1",
599            params![date.to_string()],
600            Self::map_period_row,
601        ) {
602            Ok(period) => Ok(Some(period)),
603            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
604            Err(e) => Err(map_db_error(e)),
605        }
606    }
607
608    fn list_periods(&self, filter: GlPeriodFilter) -> Result<Vec<GlPeriod>> {
609        let conn = self
610            .pool
611            .get()
612            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
613
614        let mut sql = String::from(
615            "SELECT id, period_name, fiscal_year, period_number, start_date, end_date,
616                    status, closed_at, closed_by, locked_at, locked_by, created_at, updated_at
617             FROM gl_periods WHERE 1=1",
618        );
619        let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
620
621        if let Some(year) = filter.fiscal_year {
622            sql.push_str(" AND fiscal_year = ?");
623            params.push(Box::new(year));
624        }
625        if let Some(status) = filter.status {
626            sql.push_str(" AND status = ?");
627            params.push(Box::new(status.to_string()));
628        }
629
630        sql.push_str(" ORDER BY fiscal_year DESC, period_number DESC");
631
632        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
633
634        let params_refs: Vec<&dyn rusqlite::ToSql> =
635            params.iter().map(std::convert::AsRef::as_ref).collect();
636        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
637        let rows =
638            stmt.query_map(params_refs.as_slice(), Self::map_period_row).map_err(map_db_error)?;
639
640        let mut periods = Vec::new();
641        for row in rows {
642            periods.push(row.map_err(map_db_error)?);
643        }
644        Ok(periods)
645    }
646
647    fn open_period(&self, id: Uuid) -> Result<GlPeriod> {
648        let conn = self
649            .pool
650            .get()
651            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
652
653        conn.execute(
654            "UPDATE gl_periods SET status = 'open' WHERE id = ?1 AND status = 'future'",
655            params![id.to_string()],
656        )
657        .map_err(map_db_error)?;
658
659        self.get_period(id)?.ok_or(stateset_core::CommerceError::NotFound)
660    }
661
662    fn close_period(&self, id: Uuid, closed_by: &str) -> Result<GlPeriod> {
663        let conn = self
664            .pool
665            .get()
666            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
667        let now = Utc::now();
668
669        conn.execute(
670            "UPDATE gl_periods SET status = 'closed', closed_at = ?1, closed_by = ?2
671             WHERE id = ?3 AND status = 'open'",
672            params![now.to_rfc3339(), closed_by, id.to_string()],
673        )
674        .map_err(map_db_error)?;
675
676        self.get_period(id)?.ok_or(stateset_core::CommerceError::NotFound)
677    }
678
679    fn lock_period(&self, id: Uuid, locked_by: &str) -> Result<GlPeriod> {
680        let conn = self
681            .pool
682            .get()
683            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
684        let now = Utc::now();
685
686        conn.execute(
687            "UPDATE gl_periods SET status = 'locked', locked_at = ?1, locked_by = ?2
688             WHERE id = ?3 AND status = 'closed'",
689            params![now.to_rfc3339(), locked_by, id.to_string()],
690        )
691        .map_err(map_db_error)?;
692
693        self.get_period(id)?.ok_or(stateset_core::CommerceError::NotFound)
694    }
695
696    fn reopen_period(&self, id: Uuid) -> Result<GlPeriod> {
697        let conn = self
698            .pool
699            .get()
700            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
701
702        conn.execute(
703            "UPDATE gl_periods SET status = 'open', closed_at = NULL, closed_by = NULL
704             WHERE id = ?1 AND status = 'closed'",
705            params![id.to_string()],
706        )
707        .map_err(map_db_error)?;
708
709        self.get_period(id)?.ok_or(stateset_core::CommerceError::NotFound)
710    }
711
712    // ========================================================================
713    // Journal Entries
714    // ========================================================================
715
716    fn create_journal_entry(&self, input: CreateJournalEntry) -> Result<JournalEntry> {
717        let mut conn = self
718            .pool
719            .get()
720            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
721        let id = Uuid::new_v4();
722        let now = Utc::now();
723        let entry_number = generate_journal_entry_number();
724
725        // Get period for entry date
726        let period = self.get_period_for_date(input.entry_date)?.ok_or_else(|| {
727            stateset_core::CommerceError::ValidationError(format!(
728                "No period found for date {}",
729                input.entry_date
730            ))
731        })?;
732
733        if !period.can_post() {
734            return Err(stateset_core::CommerceError::ValidationError(
735                "Period is not open for posting".to_string(),
736            ));
737        }
738
739        // Calculate totals
740        let total_debits: Decimal = input.lines.iter().map(|l| l.debit_amount).sum();
741        let total_credits: Decimal = input.lines.iter().map(|l| l.credit_amount).sum();
742        let is_balanced = total_debits == total_credits;
743        let lines_are_valid = input.lines.iter().all(|l| {
744            (l.debit_amount > Decimal::ZERO && l.credit_amount == Decimal::ZERO)
745                || (l.debit_amount == Decimal::ZERO && l.credit_amount > Decimal::ZERO)
746        });
747
748        if !lines_are_valid {
749            return Err(stateset_core::CommerceError::ValidationError(
750                "Each journal line must have either a positive debit or a positive credit"
751                    .to_string(),
752            ));
753        }
754
755        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
756
757        tx.execute(
758            "INSERT INTO gl_journal_entries (id, entry_number, entry_date, period_id,
759             entry_type, source, source_document_type, source_document_id, description,
760             total_debits, total_credits, is_balanced, status, created_at, updated_at)
761             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
762            params![
763                id.to_string(),
764                entry_number,
765                input.entry_date.to_string(),
766                period.id.to_string(),
767                input.entry_type.unwrap_or(JournalEntryType::Standard).to_string(),
768                JournalEntrySource::Manual.to_string(),
769                input.source_document_type,
770                input.source_document_id.map(|id| id.to_string()),
771                input.description,
772                total_debits.to_string(),
773                total_credits.to_string(),
774                i32::from(is_balanced),
775                JournalEntryStatus::Draft.to_string(),
776                now.to_rfc3339(),
777                now.to_rfc3339(),
778            ],
779        )
780        .map_err(map_db_error)?;
781
782        // Insert lines
783        for (line_num, line) in input.lines.iter().enumerate() {
784            let line_id = Uuid::new_v4();
785
786            // Get account info in the same transaction
787            let account: GlAccount = tx
788                .query_row(
789                    "SELECT id, account_number, name, description, account_type, account_sub_type,
790                            parent_account_id, is_header, is_posting, normal_balance, currency,
791                            status, current_balance, created_at, updated_at
792                     FROM gl_accounts WHERE id = ?1",
793                    params![line.account_id.to_string()],
794                    Self::map_account_row,
795                )
796                .map_err(map_db_error)?;
797
798            tx.execute(
799                "INSERT INTO gl_journal_entry_lines (id, journal_entry_id, line_number,
800                 account_id, account_number, account_name, description, debit_amount,
801                 credit_amount, currency, reference_type, reference_id, created_at)
802                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
803                params![
804                    line_id.to_string(),
805                    id.to_string(),
806                    (line_num + 1) as i32,
807                    line.account_id.to_string(),
808                    account.account_number,
809                    account.name,
810                    line.description,
811                    line.debit_amount.to_string(),
812                    line.credit_amount.to_string(),
813                    account.currency,
814                    line.reference_type,
815                    line.reference_id.map(|id| id.to_string()),
816                    now.to_rfc3339(),
817                ],
818            )
819            .map_err(map_db_error)?;
820        }
821
822        tx.commit().map_err(map_db_error)?;
823
824        // Auto-post if requested
825        if input.auto_post.unwrap_or(false) && is_balanced {
826            return self.post_journal_entry(id, "system");
827        }
828
829        self.get_journal_entry(id)?.ok_or(stateset_core::CommerceError::NotFound)
830    }
831
832    fn get_journal_entry(&self, id: Uuid) -> Result<Option<JournalEntry>> {
833        let conn = self
834            .pool
835            .get()
836            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
837
838        let entry = match conn.query_row(
839            "SELECT id, entry_number, entry_date, period_id, entry_type, source,
840                    source_document_type, source_document_id, description, total_debits,
841                    total_credits, is_balanced, status, posted_at, posted_by,
842                    reversed_entry_id, reversing_entry_id, created_at, updated_at
843             FROM gl_journal_entries WHERE id = ?1",
844            params![id.to_string()],
845            Self::map_journal_entry_row,
846        ) {
847            Ok(mut entry) => {
848                entry.lines = self.get_journal_entry_lines(id)?;
849                entry
850            }
851            Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
852            Err(e) => return Err(map_db_error(e)),
853        };
854
855        Ok(Some(entry))
856    }
857
858    fn get_journal_entry_by_number(&self, number: &str) -> Result<Option<JournalEntry>> {
859        let conn = self
860            .pool
861            .get()
862            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
863
864        let id: String = match conn.query_row(
865            "SELECT id FROM gl_journal_entries WHERE entry_number = ?1",
866            params![number],
867            |row| row.get(0),
868        ) {
869            Ok(id) => id,
870            Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
871            Err(e) => return Err(map_db_error(e)),
872        };
873
874        let entry_id = parse_uuid(&id, "gl_journal_entry", "id")?;
875        self.get_journal_entry(entry_id)
876    }
877
878    fn list_journal_entries(&self, filter: JournalEntryFilter) -> Result<Vec<JournalEntry>> {
879        let conn = self
880            .pool
881            .get()
882            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
883
884        let mut sql = String::from(
885            "SELECT id, entry_number, entry_date, period_id, entry_type, source,
886                    source_document_type, source_document_id, description, total_debits,
887                    total_credits, is_balanced, status, posted_at, posted_by,
888                    reversed_entry_id, reversing_entry_id, created_at, updated_at
889             FROM gl_journal_entries WHERE 1=1",
890        );
891        let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
892
893        if let Some(period_id) = filter.period_id {
894            sql.push_str(" AND period_id = ?");
895            params.push(Box::new(period_id.to_string()));
896        }
897        if let Some(entry_type) = filter.entry_type {
898            sql.push_str(" AND entry_type = ?");
899            params.push(Box::new(entry_type.to_string()));
900        }
901        if let Some(source) = filter.source {
902            sql.push_str(" AND source = ?");
903            params.push(Box::new(source.to_string()));
904        }
905        if let Some(status) = filter.status {
906            sql.push_str(" AND status = ?");
907            params.push(Box::new(status.to_string()));
908        }
909        if let Some(from_date) = filter.from_date {
910            sql.push_str(" AND entry_date >= ?");
911            params.push(Box::new(from_date.to_string()));
912        }
913        if let Some(to_date) = filter.to_date {
914            sql.push_str(" AND entry_date <= ?");
915            params.push(Box::new(to_date.to_string()));
916        }
917        if let Some(doc_type) = filter.source_document_type {
918            sql.push_str(" AND source_document_type = ?");
919            params.push(Box::new(doc_type));
920        }
921        if let Some(doc_id) = filter.source_document_id {
922            sql.push_str(" AND source_document_id = ?");
923            params.push(Box::new(doc_id.to_string()));
924        }
925        // `account_id` selects entries with a line posting to that account. Postgres
926        // joins `gl_journal_entry_lines` with `SELECT DISTINCT`; the `id IN (…)`
927        // subquery is the equivalent here and cannot produce duplicate rows.
928        if let Some(account_id) = filter.account_id {
929            sql.push_str(
930                " AND id IN (SELECT journal_entry_id FROM gl_journal_entry_lines \
931                 WHERE account_id = ?)",
932            );
933            params.push(Box::new(account_id.to_string()));
934        }
935        // Free-text search over entry number / description (Postgres uses `ILIKE`;
936        // SQLite `LIKE` is case-insensitive for ASCII).
937        if let Some(search) = filter.search {
938            sql.push_str(" AND (entry_number LIKE ? OR description LIKE ?)");
939            let term = format!("%{search}%");
940            params.push(Box::new(term.clone()));
941            params.push(Box::new(term));
942        }
943
944        sql.push_str(" ORDER BY entry_date DESC, entry_number DESC");
945
946        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
947
948        let params_refs: Vec<&dyn rusqlite::ToSql> =
949            params.iter().map(std::convert::AsRef::as_ref).collect();
950        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
951        let rows = stmt
952            .query_map(params_refs.as_slice(), Self::map_journal_entry_row)
953            .map_err(map_db_error)?;
954
955        let mut entries = Vec::new();
956        for row in rows {
957            let mut entry = row.map_err(map_db_error)?;
958            entry.lines = self.get_journal_entry_lines(entry.id)?;
959            entries.push(entry);
960        }
961        Ok(entries)
962    }
963
964    fn post_journal_entry(&self, id: Uuid, posted_by: &str) -> Result<JournalEntry> {
965        let now = Utc::now();
966
967        with_immediate_transaction(&self.pool, |tx| {
968            // Re-read inside the write transaction so a concurrent poster
969            // cannot pass validation against stale state.
970            let entry = Self::load_journal_entry_with_conn(tx, id)?;
971
972            if !entry.can_post() {
973                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
974                    stateset_core::CommerceError::ValidationError(
975                        "Entry cannot be posted - must be draft and balanced".to_string(),
976                    ),
977                )));
978            }
979
980            for line in &entry.lines {
981                Self::update_account_balance_with_conn(
982                    tx,
983                    line.account_id,
984                    line.debit_amount,
985                    line.credit_amount,
986                )
987                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
988            }
989
990            // Guard on status so the balance updates above can only commit
991            // together with exactly one draft -> posted transition.
992            let rows_affected = tx.execute(
993                "UPDATE gl_journal_entries SET status = 'posted', posted_at = ?1, posted_by = ?2
994                 WHERE id = ?3 AND status = 'draft'",
995                params![now.to_rfc3339(), posted_by, id.to_string()],
996            )?;
997            if rows_affected == 0 {
998                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
999                    stateset_core::CommerceError::Conflict(
1000                        "Journal entry was modified concurrently".to_string(),
1001                    ),
1002                )));
1003            }
1004
1005            Ok(())
1006        })?;
1007
1008        self.get_journal_entry(id)?.ok_or(stateset_core::CommerceError::NotFound)
1009    }
1010
1011    fn void_journal_entry(&self, id: Uuid) -> Result<JournalEntry> {
1012        with_immediate_transaction(&self.pool, |tx| {
1013            // Re-read inside the write transaction so a concurrent voider
1014            // cannot pass validation against stale state.
1015            let entry = Self::load_journal_entry_with_conn(tx, id)?;
1016
1017            if !entry.can_void() {
1018                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1019                    stateset_core::CommerceError::ValidationError(
1020                        "Entry cannot be voided - must be posted".to_string(),
1021                    ),
1022                )));
1023            }
1024
1025            // Reverse account balances
1026            for line in &entry.lines {
1027                Self::update_account_balance_with_conn(
1028                    tx,
1029                    line.account_id,
1030                    line.credit_amount,
1031                    line.debit_amount,
1032                )
1033                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
1034            }
1035
1036            // Guard on status so the balance reversal above can only commit
1037            // together with exactly one posted -> voided transition.
1038            let rows_affected = tx.execute(
1039                "UPDATE gl_journal_entries SET status = 'voided' WHERE id = ?1 AND status = 'posted'",
1040                params![id.to_string()],
1041            )?;
1042            if rows_affected == 0 {
1043                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1044                    stateset_core::CommerceError::Conflict(
1045                        "Journal entry was modified concurrently".to_string(),
1046                    ),
1047                )));
1048            }
1049
1050            Ok(())
1051        })?;
1052
1053        self.get_journal_entry(id)?.ok_or(stateset_core::CommerceError::NotFound)
1054    }
1055
1056    fn reverse_journal_entry(&self, id: Uuid, reversal_date: NaiveDate) -> Result<JournalEntry> {
1057        let entry = self.get_journal_entry(id)?.ok_or(stateset_core::CommerceError::NotFound)?;
1058
1059        if entry.status != JournalEntryStatus::Posted {
1060            return Err(stateset_core::CommerceError::ValidationError(
1061                "Can only reverse posted entries".to_string(),
1062            ));
1063        }
1064
1065        // Claim the entry (posted -> reversed) before creating the reversing
1066        // entry, which commits its own transactions; the status guard ensures
1067        // concurrent reversals cannot both create (and auto-post) a reversal.
1068        let conn = self
1069            .pool
1070            .get()
1071            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1072        let claimed = conn
1073            .execute(
1074                "UPDATE gl_journal_entries SET status = 'reversed' WHERE id = ?1 AND status = 'posted'",
1075                params![id.to_string()],
1076            )
1077            .map_err(map_db_error)?;
1078        if claimed == 0 {
1079            return Err(stateset_core::CommerceError::Conflict(
1080                "Journal entry was modified concurrently".to_string(),
1081            ));
1082        }
1083
1084        // Create reversing entry with swapped debits/credits
1085        let reversing_lines: Vec<_> = entry
1086            .lines
1087            .iter()
1088            .map(|l| stateset_core::CreateJournalEntryLine {
1089                account_id: l.account_id,
1090                description: Some(format!("Reversal of {}", entry.entry_number)),
1091                debit_amount: l.credit_amount,
1092                credit_amount: l.debit_amount,
1093                reference_type: l.reference_type.clone(),
1094                reference_id: l.reference_id,
1095            })
1096            .collect();
1097
1098        let reversing_entry = match self.create_journal_entry(stateset_core::CreateJournalEntry {
1099            entry_date: reversal_date,
1100            entry_type: Some(JournalEntryType::Reversing),
1101            description: format!("Reversal of {}", entry.entry_number),
1102            lines: reversing_lines,
1103            source_document_type: Some("reversal".to_string()),
1104            source_document_id: Some(entry.id),
1105            auto_post: Some(true),
1106        }) {
1107            Ok(reversing_entry) => reversing_entry,
1108            Err(e) => {
1109                // Best-effort release of the claim so the entry is not left
1110                // marked reversed without a reversing entry.
1111                let _ = conn.execute(
1112                    "UPDATE gl_journal_entries SET status = 'posted' WHERE id = ?1 AND status = 'reversed'",
1113                    params![id.to_string()],
1114                );
1115                return Err(e);
1116            }
1117        };
1118
1119        // Link entries
1120        conn.execute(
1121            "UPDATE gl_journal_entries SET reversing_entry_id = ?1 WHERE id = ?2",
1122            params![reversing_entry.id.to_string(), id.to_string()],
1123        )
1124        .map_err(map_db_error)?;
1125
1126        conn.execute(
1127            "UPDATE gl_journal_entries SET reversed_entry_id = ?1 WHERE id = ?2",
1128            params![id.to_string(), reversing_entry.id.to_string()],
1129        )
1130        .map_err(map_db_error)?;
1131
1132        self.get_journal_entry(reversing_entry.id)?.ok_or(stateset_core::CommerceError::NotFound)
1133    }
1134
1135    fn get_journal_entry_lines(&self, journal_entry_id: Uuid) -> Result<Vec<JournalEntryLine>> {
1136        let conn = self
1137            .pool
1138            .get()
1139            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1140
1141        let mut stmt = conn
1142            .prepare(
1143                "SELECT id, journal_entry_id, line_number, account_id, account_number,
1144                    account_name, description, debit_amount, credit_amount, currency,
1145                    reference_type, reference_id, created_at
1146             FROM gl_journal_entry_lines WHERE journal_entry_id = ?1 ORDER BY line_number",
1147            )
1148            .map_err(map_db_error)?;
1149
1150        let rows = stmt
1151            .query_map(params![journal_entry_id.to_string()], Self::map_journal_entry_line_row)
1152            .map_err(map_db_error)?;
1153
1154        let mut lines = Vec::new();
1155        for row in rows {
1156            lines.push(row.map_err(map_db_error)?);
1157        }
1158        Ok(lines)
1159    }
1160
1161    // ========================================================================
1162    // Auto-posting
1163    // ========================================================================
1164
1165    fn get_auto_posting_config(&self) -> Result<Option<AutoPostingConfig>> {
1166        let conn = self
1167            .pool
1168            .get()
1169            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1170
1171        match conn.query_row(
1172            "SELECT id, config_name, cash_account_id, accounts_receivable_account_id,
1173                    inventory_account_id, accounts_payable_account_id, unearned_revenue_account_id,
1174                    sales_revenue_account_id, shipping_revenue_account_id, cogs_account_id,
1175                    bad_debt_expense_account_id, fx_gain_loss_account_id, auto_post_depreciation,
1176                    auto_post_revenue_recognition, is_active, created_at, updated_at
1177             FROM gl_auto_posting_config WHERE is_active = 1 LIMIT 1",
1178            [],
1179            Self::map_auto_posting_config_row,
1180        ) {
1181            Ok(config) => Ok(Some(config)),
1182            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1183            Err(e) => Err(map_db_error(e)),
1184        }
1185    }
1186
1187    fn set_auto_posting_config(&self, input: CreateAutoPostingConfig) -> Result<AutoPostingConfig> {
1188        let conn = self
1189            .pool
1190            .get()
1191            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1192        let id = Uuid::new_v4();
1193        let now = Utc::now();
1194
1195        // Deactivate existing configs
1196        conn.execute("UPDATE gl_auto_posting_config SET is_active = 0", [])
1197            .map_err(map_db_error)?;
1198
1199        conn.execute(
1200            "INSERT INTO gl_auto_posting_config (id, config_name, cash_account_id,
1201             accounts_receivable_account_id, inventory_account_id, accounts_payable_account_id,
1202             unearned_revenue_account_id, sales_revenue_account_id, shipping_revenue_account_id,
1203             cogs_account_id, bad_debt_expense_account_id, fx_gain_loss_account_id,
1204             auto_post_depreciation,
1205             auto_post_revenue_recognition, is_active, created_at, updated_at)
1206             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
1207            params![
1208                id.to_string(),
1209                input.config_name,
1210                input.cash_account_id.to_string(),
1211                input.accounts_receivable_account_id.to_string(),
1212                input.inventory_account_id.to_string(),
1213                input.accounts_payable_account_id.to_string(),
1214                input.unearned_revenue_account_id.map(|id| id.to_string()),
1215                input.sales_revenue_account_id.to_string(),
1216                input.shipping_revenue_account_id.map(|id| id.to_string()),
1217                input.cogs_account_id.to_string(),
1218                input.bad_debt_expense_account_id.map(|id| id.to_string()),
1219                input.fx_gain_loss_account_id.map(|id| id.to_string()),
1220                i32::from(input.auto_post_depreciation),
1221                i32::from(input.auto_post_revenue_recognition),
1222                1,
1223                now.to_rfc3339(),
1224                now.to_rfc3339(),
1225            ],
1226        )
1227        .map_err(map_db_error)?;
1228
1229        self.get_auto_posting_config()?.ok_or(stateset_core::CommerceError::NotFound)
1230    }
1231
1232    fn auto_post_invoice(&self, invoice_id: InvoiceId) -> Result<JournalEntry> {
1233        let config = self.get_auto_posting_config()?.ok_or_else(|| {
1234            stateset_core::CommerceError::ValidationError("Auto-posting not configured".to_string())
1235        })?;
1236
1237        let conn = self
1238            .pool
1239            .get()
1240            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1241
1242        // Get invoice details. The money column is `total` (not `total_amount`),
1243        // and `invoice_date` is stored as a full RFC3339 timestamp, so it must be
1244        // parsed as a datetime and reduced to its date (matching Postgres, which
1245        // reads a `DateTime<Utc>` and calls `.date_naive()`).
1246        let (total, invoice_date): (String, String) = conn
1247            .query_row(
1248                "SELECT total, invoice_date FROM invoices WHERE id = ?1",
1249                params![invoice_id.to_string()],
1250                |row| Ok((row.get(0)?, row.get(1)?)),
1251            )
1252            .map_err(map_db_error)?;
1253
1254        let amount = parse_decimal_required(total, 0).map_err(map_db_error)?;
1255        let entry_date: NaiveDate = chrono::DateTime::parse_from_rfc3339(&invoice_date)
1256            .map_err(|e| {
1257                stateset_core::CommerceError::DatabaseError(format!(
1258                    "invalid invoice_date {invoice_date:?}: {e}"
1259                ))
1260            })?
1261            .date_naive();
1262
1263        self.create_journal_entry(stateset_core::CreateJournalEntry {
1264            entry_date,
1265            entry_type: Some(JournalEntryType::Standard),
1266            description: format!("Invoice {invoice_id}"),
1267            lines: vec![
1268                stateset_core::CreateJournalEntryLine::debit(
1269                    config.accounts_receivable_account_id,
1270                    amount,
1271                    Some("Accounts Receivable".to_string()),
1272                ),
1273                stateset_core::CreateJournalEntryLine::credit(
1274                    config.sales_revenue_account_id,
1275                    amount,
1276                    Some("Sales Revenue".to_string()),
1277                ),
1278            ],
1279            source_document_type: Some("invoice".to_string()),
1280            source_document_id: Some(invoice_id.into()),
1281            auto_post: Some(true),
1282        })
1283    }
1284
1285    fn auto_post_payment_received(&self, payment_id: Uuid) -> Result<JournalEntry> {
1286        let config = self.get_auto_posting_config()?.ok_or_else(|| {
1287            stateset_core::CommerceError::ValidationError("Auto-posting not configured".to_string())
1288        })?;
1289
1290        let conn = self
1291            .pool
1292            .get()
1293            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1294
1295        // The `payments` table has no `payment_date` column; the payment date is
1296        // `paid_at` (nullable) falling back to `created_at`, stored as full RFC3339
1297        // timestamps. Match Postgres, which reads `COALESCE(paid_at, created_at)`
1298        // as a `DateTime<Utc>` and reduces it with `.date_naive()`.
1299        let (amount_str, payment_date): (String, String) = conn
1300            .query_row(
1301                "SELECT amount, COALESCE(paid_at, created_at) FROM payments WHERE id = ?1",
1302                params![payment_id.to_string()],
1303                |row| Ok((row.get(0)?, row.get(1)?)),
1304            )
1305            .map_err(map_db_error)?;
1306
1307        let amount = parse_decimal_required(amount_str, 0).map_err(map_db_error)?;
1308        let entry_date: NaiveDate = chrono::DateTime::parse_from_rfc3339(&payment_date)
1309            .map_err(|e| {
1310                stateset_core::CommerceError::DatabaseError(format!(
1311                    "invalid payment date {payment_date:?}: {e}"
1312                ))
1313            })?
1314            .date_naive();
1315
1316        self.create_journal_entry(stateset_core::CreateJournalEntry {
1317            entry_date,
1318            entry_type: Some(JournalEntryType::Standard),
1319            description: format!("Payment {payment_id}"),
1320            lines: vec![
1321                stateset_core::CreateJournalEntryLine::debit(
1322                    config.cash_account_id,
1323                    amount,
1324                    Some("Cash".to_string()),
1325                ),
1326                stateset_core::CreateJournalEntryLine::credit(
1327                    config.accounts_receivable_account_id,
1328                    amount,
1329                    Some("Accounts Receivable".to_string()),
1330                ),
1331            ],
1332            source_document_type: Some("payment".to_string()),
1333            source_document_id: Some(payment_id),
1334            auto_post: Some(true),
1335        })
1336    }
1337
1338    fn auto_post_bill(&self, bill_id: Uuid) -> Result<JournalEntry> {
1339        let config = self.get_auto_posting_config()?.ok_or_else(|| {
1340            stateset_core::CommerceError::ValidationError("Auto-posting not configured".to_string())
1341        })?;
1342
1343        let conn = self
1344            .pool
1345            .get()
1346            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1347
1348        // AP bills live in `ap_bills` (there is no `bills` table), and `bill_date`
1349        // is stored as a full RFC3339 timestamp, so it must be parsed as a datetime
1350        // and reduced to its date (Postgres reads `ap_bills` with a native `DATE`).
1351        let (total, bill_date): (String, String) = conn
1352            .query_row(
1353                "SELECT total_amount, bill_date FROM ap_bills WHERE id = ?1",
1354                params![bill_id.to_string()],
1355                |row| Ok((row.get(0)?, row.get(1)?)),
1356            )
1357            .map_err(map_db_error)?;
1358
1359        let amount = parse_decimal_required(total, 0).map_err(map_db_error)?;
1360        let entry_date: NaiveDate = chrono::DateTime::parse_from_rfc3339(&bill_date)
1361            .map_err(|e| {
1362                stateset_core::CommerceError::DatabaseError(format!(
1363                    "invalid bill_date {bill_date:?}: {e}"
1364                ))
1365            })?
1366            .date_naive();
1367
1368        self.create_journal_entry(stateset_core::CreateJournalEntry {
1369            entry_date,
1370            entry_type: Some(JournalEntryType::Standard),
1371            description: format!("Bill {bill_id}"),
1372            lines: vec![
1373                stateset_core::CreateJournalEntryLine::debit(
1374                    config.inventory_account_id,
1375                    amount,
1376                    Some("Inventory/Expense".to_string()),
1377                ),
1378                stateset_core::CreateJournalEntryLine::credit(
1379                    config.accounts_payable_account_id,
1380                    amount,
1381                    Some("Accounts Payable".to_string()),
1382                ),
1383            ],
1384            source_document_type: Some("bill".to_string()),
1385            source_document_id: Some(bill_id),
1386            auto_post: Some(true),
1387        })
1388    }
1389
1390    fn auto_post_bill_payment(&self, payment_id: Uuid) -> Result<JournalEntry> {
1391        let config = self.get_auto_posting_config()?.ok_or_else(|| {
1392            stateset_core::CommerceError::ValidationError("Auto-posting not configured".to_string())
1393        })?;
1394
1395        let conn = self
1396            .pool
1397            .get()
1398            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1399
1400        // AP payments live in `ap_payments` (there is no `bill_payments` table), and
1401        // `payment_date` is stored as a full RFC3339 timestamp, so it must be parsed
1402        // as a datetime and reduced to its date (Postgres reads `ap_payments` with a
1403        // native `DATE`).
1404        let (amount_str, payment_date): (String, String) = conn
1405            .query_row(
1406                "SELECT amount, payment_date FROM ap_payments WHERE id = ?1",
1407                params![payment_id.to_string()],
1408                |row| Ok((row.get(0)?, row.get(1)?)),
1409            )
1410            .map_err(map_db_error)?;
1411
1412        let amount = parse_decimal_required(amount_str, 0).map_err(map_db_error)?;
1413        let entry_date: NaiveDate = chrono::DateTime::parse_from_rfc3339(&payment_date)
1414            .map_err(|e| {
1415                stateset_core::CommerceError::DatabaseError(format!(
1416                    "invalid payment date {payment_date:?}: {e}"
1417                ))
1418            })?
1419            .date_naive();
1420
1421        self.create_journal_entry(stateset_core::CreateJournalEntry {
1422            entry_date,
1423            entry_type: Some(JournalEntryType::Standard),
1424            description: format!("Bill Payment {payment_id}"),
1425            lines: vec![
1426                stateset_core::CreateJournalEntryLine::debit(
1427                    config.accounts_payable_account_id,
1428                    amount,
1429                    Some("Accounts Payable".to_string()),
1430                ),
1431                stateset_core::CreateJournalEntryLine::credit(
1432                    config.cash_account_id,
1433                    amount,
1434                    Some("Cash".to_string()),
1435                ),
1436            ],
1437            source_document_type: Some("bill_payment".to_string()),
1438            source_document_id: Some(payment_id),
1439            auto_post: Some(true),
1440        })
1441    }
1442
1443    fn auto_post_inventory_cost(&self, cost_transaction_id: Uuid) -> Result<JournalEntry> {
1444        let config = self.get_auto_posting_config()?.ok_or_else(|| {
1445            stateset_core::CommerceError::ValidationError("Auto-posting not configured".to_string())
1446        })?;
1447
1448        let conn = self
1449            .pool
1450            .get()
1451            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1452
1453        // The date column is `created_at` (there is no `transaction_date`), stored
1454        // as a full RFC3339 timestamp, so it must be parsed as a datetime and
1455        // reduced to its date (Postgres reads `created_at` as a `DateTime<Utc>` and
1456        // calls `.date_naive()`).
1457        let (cost_str, created_at, transaction_type): (String, String, String) = conn.query_row(
1458            "SELECT total_cost, created_at, transaction_type FROM cost_transactions WHERE id = ?1",
1459            params![cost_transaction_id.to_string()],
1460            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1461        ).map_err(map_db_error)?;
1462
1463        let cost = parse_decimal_required(cost_str, 0).map_err(map_db_error)?;
1464        let entry_date: NaiveDate = chrono::DateTime::parse_from_rfc3339(&created_at)
1465            .map_err(|e| {
1466                stateset_core::CommerceError::DatabaseError(format!(
1467                    "invalid created_at {created_at:?}: {e}"
1468                ))
1469            })?
1470            .date_naive();
1471
1472        // An inventory issue/sale moves cost out of Inventory into COGS (debit COGS,
1473        // credit Inventory); anything else (a receipt) does the reverse. Postgres
1474        // treats both `"issue"` and `"sale"` as issues — matching that here fixes an
1475        // `"issue"` transaction posting with debit/credit reversed on SQLite.
1476        let is_issue = transaction_type == "issue" || transaction_type == "sale";
1477        let (debit_account, credit_account) = if is_issue {
1478            (config.cogs_account_id, config.inventory_account_id)
1479        } else {
1480            (config.inventory_account_id, config.cogs_account_id)
1481        };
1482
1483        self.create_journal_entry(stateset_core::CreateJournalEntry {
1484            entry_date,
1485            entry_type: Some(JournalEntryType::Standard),
1486            description: format!("Inventory Cost {cost_transaction_id}"),
1487            lines: vec![
1488                stateset_core::CreateJournalEntryLine::debit(
1489                    debit_account,
1490                    cost,
1491                    Some(if is_issue { "COGS" } else { "Inventory" }.to_string()),
1492                ),
1493                stateset_core::CreateJournalEntryLine::credit(
1494                    credit_account,
1495                    cost,
1496                    Some(if is_issue { "Inventory" } else { "COGS" }.to_string()),
1497                ),
1498            ],
1499            source_document_type: Some("cost_transaction".to_string()),
1500            source_document_id: Some(cost_transaction_id),
1501            auto_post: Some(true),
1502        })
1503    }
1504
1505    fn auto_post_write_off(&self, write_off_id: Uuid) -> Result<JournalEntry> {
1506        let config = self.get_auto_posting_config()?.ok_or_else(|| {
1507            stateset_core::CommerceError::ValidationError("Auto-posting not configured".to_string())
1508        })?;
1509
1510        let bad_debt_account = config.bad_debt_expense_account_id.ok_or_else(|| {
1511            stateset_core::CommerceError::ValidationError(
1512                "Bad debt expense account not configured".to_string(),
1513            )
1514        })?;
1515
1516        let conn = self
1517            .pool
1518            .get()
1519            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1520
1521        let (amount_str, write_off_date): (String, String) = conn
1522            .query_row(
1523                "SELECT amount, write_off_date FROM ar_write_offs WHERE id = ?1",
1524                params![write_off_id.to_string()],
1525                |row| Ok((row.get(0)?, row.get(1)?)),
1526            )
1527            .map_err(map_db_error)?;
1528
1529        let amount = parse_decimal_required(amount_str, 0).map_err(map_db_error)?;
1530        let entry_date: NaiveDate = parse_required(write_off_date, 1).map_err(map_db_error)?;
1531
1532        self.create_journal_entry(stateset_core::CreateJournalEntry {
1533            entry_date,
1534            entry_type: Some(JournalEntryType::Standard),
1535            description: format!("Write-off {write_off_id}"),
1536            lines: vec![
1537                stateset_core::CreateJournalEntryLine::debit(
1538                    bad_debt_account,
1539                    amount,
1540                    Some("Bad Debt Expense".to_string()),
1541                ),
1542                stateset_core::CreateJournalEntryLine::credit(
1543                    config.accounts_receivable_account_id,
1544                    amount,
1545                    Some("Accounts Receivable".to_string()),
1546                ),
1547            ],
1548            source_document_type: Some("write_off".to_string()),
1549            source_document_id: Some(write_off_id),
1550            auto_post: Some(true),
1551        })
1552    }
1553
1554    // ========================================================================
1555    // Financial Reports
1556    // ========================================================================
1557
1558    fn get_trial_balance(&self, as_of_date: NaiveDate) -> Result<TrialBalance> {
1559        let conn = self
1560            .pool
1561            .get()
1562            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1563
1564        let mut stmt = conn.prepare(
1565            "SELECT a.id, a.account_number, a.name, a.account_type, a.normal_balance, a.current_balance
1566             FROM gl_accounts a
1567             WHERE a.is_posting = 1 AND a.status = 'active'
1568             ORDER BY a.account_number"
1569        ).map_err(map_db_error)?;
1570
1571        let rows = stmt
1572            .query_map([], |row| {
1573                let balance = parse_decimal_required(row.get::<_, String>(5)?, 5)?;
1574                let normal_balance: BalanceSide = parse_required(row.get::<_, String>(4)?, 4)?;
1575
1576                let (debit_balance, credit_balance) = match normal_balance {
1577                    BalanceSide::Debit => (balance, Decimal::ZERO),
1578                    BalanceSide::Credit => (Decimal::ZERO, balance),
1579                    _ => (balance, Decimal::ZERO),
1580                };
1581
1582                Ok(TrialBalanceLine {
1583                    account_id: parse_required(row.get::<_, String>(0)?, 0)?,
1584                    account_number: row.get(1)?,
1585                    account_name: row.get(2)?,
1586                    account_type: parse_required(row.get::<_, String>(3)?, 3)?,
1587                    debit_balance,
1588                    credit_balance,
1589                })
1590            })
1591            .map_err(map_db_error)?;
1592
1593        let mut lines = Vec::new();
1594        let mut total_debits = Decimal::ZERO;
1595        let mut total_credits = Decimal::ZERO;
1596
1597        for row in rows {
1598            let line = row.map_err(map_db_error)?;
1599            total_debits += line.debit_balance;
1600            total_credits += line.credit_balance;
1601            lines.push(line);
1602        }
1603
1604        Ok(TrialBalance {
1605            as_of_date,
1606            period_id: None,
1607            total_debits,
1608            total_credits,
1609            is_balanced: total_debits == total_credits,
1610            lines,
1611        })
1612    }
1613
1614    fn get_balance_sheet(&self, as_of_date: NaiveDate) -> Result<BalanceSheet> {
1615        let conn = self
1616            .pool
1617            .get()
1618            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1619
1620        let mut assets = Vec::new();
1621        let mut liabilities = Vec::new();
1622        let mut equity = Vec::new();
1623        let mut total_assets = Decimal::ZERO;
1624        let mut total_liabilities = Decimal::ZERO;
1625        let mut total_equity = Decimal::ZERO;
1626
1627        let mut stmt = conn.prepare(
1628            "SELECT id, account_number, name, account_type, account_sub_type, current_balance, normal_balance
1629             FROM gl_accounts
1630             WHERE is_posting = 1 AND status = 'active'
1631               AND account_type IN ('asset', 'liability', 'equity')
1632             ORDER BY account_number"
1633        ).map_err(map_db_error)?;
1634
1635        let rows = stmt
1636            .query_map([], |row| {
1637                let balance = parse_decimal_required(row.get::<_, String>(5)?, 5)?;
1638                let normal_balance: BalanceSide = parse_required(row.get::<_, String>(6)?, 6)?;
1639
1640                let display_balance = match normal_balance {
1641                    BalanceSide::Debit => balance,
1642                    BalanceSide::Credit => balance,
1643                    _ => balance,
1644                };
1645
1646                Ok((
1647                    parse_required(row.get::<_, String>(0)?, 0)?,
1648                    row.get::<_, String>(1)?,
1649                    row.get::<_, String>(2)?,
1650                    parse_required(row.get::<_, String>(3)?, 3)?,
1651                    parse_optional(row.get::<_, Option<String>>(4)?, 4)?,
1652                    display_balance,
1653                ))
1654            })
1655            .map_err(map_db_error)?;
1656
1657        for row in rows {
1658            let (id, number, name, account_type, sub_type, balance): (
1659                Uuid,
1660                String,
1661                String,
1662                AccountType,
1663                Option<AccountSubType>,
1664                Decimal,
1665            ) = row.map_err(map_db_error)?;
1666
1667            let line = BalanceSheetLine {
1668                account_id: id,
1669                account_number: number,
1670                account_name: name,
1671                account_sub_type: sub_type,
1672                balance,
1673                indent_level: 0,
1674                is_total: false,
1675            };
1676
1677            match account_type {
1678                AccountType::Asset => {
1679                    total_assets += balance;
1680                    assets.push(line);
1681                }
1682                AccountType::Liability => {
1683                    total_liabilities += balance;
1684                    liabilities.push(line);
1685                }
1686                AccountType::Equity => {
1687                    total_equity += balance;
1688                    equity.push(line);
1689                }
1690                _ => {}
1691            }
1692        }
1693
1694        Ok(BalanceSheet {
1695            as_of_date,
1696            total_assets,
1697            total_liabilities,
1698            total_equity,
1699            assets,
1700            liabilities,
1701            equity,
1702        })
1703    }
1704
1705    fn get_income_statement(
1706        &self,
1707        start_date: NaiveDate,
1708        end_date: NaiveDate,
1709    ) -> Result<IncomeStatement> {
1710        let conn = self
1711            .pool
1712            .get()
1713            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1714
1715        let mut revenue_lines = Vec::new();
1716        let mut expense_lines = Vec::new();
1717        let mut total_revenue = Decimal::ZERO;
1718        let mut total_expenses = Decimal::ZERO;
1719
1720        // Get account activity for the period from posted journal entries
1721        let mut stmt = conn
1722            .prepare(
1723                // `debit_amount` / `credit_amount` are TEXT money columns; the
1724                // built-in SQL `SUM()` coerces them to float (lossy) and returns
1725                // a non-TEXT value that the `String` read below rejects. The
1726                // `decimal_sum` aggregate accumulates exactly and returns TEXT
1727                // ("0" for accounts with no lines).
1728                "SELECT a.id, a.account_number, a.name, a.account_type, a.account_sub_type,
1729                    decimal_sum(l.debit_amount) as total_debits,
1730                    decimal_sum(l.credit_amount) as total_credits
1731             FROM gl_accounts a
1732             LEFT JOIN gl_journal_entry_lines l ON a.id = l.account_id
1733             LEFT JOIN gl_journal_entries je ON l.journal_entry_id = je.id
1734             WHERE a.is_posting = 1 AND a.status = 'active'
1735               AND a.account_type IN ('revenue', 'expense')
1736               AND (je.status = 'posted' OR je.id IS NULL)
1737               AND (je.entry_date >= ?1 AND je.entry_date <= ?2 OR je.id IS NULL)
1738             GROUP BY a.id
1739             ORDER BY a.account_number",
1740            )
1741            .map_err(map_db_error)?;
1742
1743        let rows = stmt
1744            .query_map(params![start_date.to_string(), end_date.to_string()], |row| {
1745                let total_debits = parse_decimal_required(row.get::<_, String>(5)?, 5)?;
1746                let total_credits = parse_decimal_required(row.get::<_, String>(6)?, 6)?;
1747                let account_type: AccountType = parse_required(row.get::<_, String>(3)?, 3)?;
1748
1749                // Revenue has credit normal balance, expense has debit
1750                let amount = match account_type {
1751                    AccountType::Revenue => total_credits - total_debits,
1752                    AccountType::Expense => total_debits - total_credits,
1753                    _ => Decimal::ZERO,
1754                };
1755
1756                Ok((
1757                    parse_required(row.get::<_, String>(0)?, 0)?,
1758                    row.get::<_, String>(1)?,
1759                    row.get::<_, String>(2)?,
1760                    account_type,
1761                    parse_optional(row.get::<_, Option<String>>(4)?, 4)?,
1762                    amount,
1763                ))
1764            })
1765            .map_err(map_db_error)?;
1766
1767        for row in rows {
1768            let (id, number, name, account_type, sub_type, amount): (
1769                Uuid,
1770                String,
1771                String,
1772                AccountType,
1773                Option<AccountSubType>,
1774                Decimal,
1775            ) = row.map_err(map_db_error)?;
1776
1777            if amount == Decimal::ZERO {
1778                continue;
1779            }
1780
1781            let line = IncomeStatementLine {
1782                account_id: id,
1783                account_number: number,
1784                account_name: name,
1785                account_sub_type: sub_type,
1786                amount,
1787                indent_level: 0,
1788                is_total: false,
1789            };
1790
1791            match account_type {
1792                AccountType::Revenue => {
1793                    total_revenue += amount;
1794                    revenue_lines.push(line);
1795                }
1796                AccountType::Expense => {
1797                    total_expenses += amount;
1798                    expense_lines.push(line);
1799                }
1800                _ => {}
1801            }
1802        }
1803
1804        Ok(IncomeStatement {
1805            period_start: start_date,
1806            period_end: end_date,
1807            total_revenue,
1808            total_expenses,
1809            net_income: total_revenue - total_expenses,
1810            revenue_lines,
1811            expense_lines,
1812        })
1813    }
1814
1815    fn get_account_balance(
1816        &self,
1817        account_id: Uuid,
1818        _as_of_date: Option<NaiveDate>,
1819    ) -> Result<Option<Decimal>> {
1820        Ok(self.get_account(account_id)?.map(|account| account.current_balance))
1821    }
1822
1823    fn get_account_transactions(
1824        &self,
1825        account_id: Uuid,
1826        filter: JournalEntryFilter,
1827    ) -> Result<Vec<JournalEntryLine>> {
1828        let conn = self
1829            .pool
1830            .get()
1831            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1832
1833        let mut sql = String::from(
1834            "SELECT l.id, l.journal_entry_id, l.line_number, l.account_id, l.account_number,
1835                    l.account_name, l.description, l.debit_amount, l.credit_amount, l.currency,
1836                    l.reference_type, l.reference_id, l.created_at
1837             FROM gl_journal_entry_lines l
1838             JOIN gl_journal_entries je ON l.journal_entry_id = je.id
1839             WHERE l.account_id = ?1",
1840        );
1841        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(account_id.to_string())];
1842
1843        if let Some(status) = filter.status {
1844            sql.push_str(" AND je.status = ?");
1845            params.push(Box::new(status.to_string()));
1846        }
1847        if let Some(from_date) = filter.from_date {
1848            sql.push_str(" AND je.entry_date >= ?");
1849            params.push(Box::new(from_date.to_string()));
1850        }
1851        if let Some(to_date) = filter.to_date {
1852            sql.push_str(" AND je.entry_date <= ?");
1853            params.push(Box::new(to_date.to_string()));
1854        }
1855
1856        sql.push_str(" ORDER BY je.entry_date DESC, l.line_number");
1857
1858        if let Some(limit) = filter.limit {
1859            sql.push_str(&format!(" LIMIT {limit}"));
1860        }
1861
1862        let params_refs: Vec<&dyn rusqlite::ToSql> =
1863            params.iter().map(std::convert::AsRef::as_ref).collect();
1864        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1865        let rows = stmt
1866            .query_map(params_refs.as_slice(), Self::map_journal_entry_line_row)
1867            .map_err(map_db_error)?;
1868
1869        let mut lines = Vec::new();
1870        for row in rows {
1871            lines.push(row.map_err(map_db_error)?);
1872        }
1873        Ok(lines)
1874    }
1875
1876    fn run_period_close(&self, period_id: Uuid, closed_by: &str) -> Result<JournalEntry> {
1877        let period = self.get_period(period_id)?.ok_or(stateset_core::CommerceError::NotFound)?;
1878
1879        if period.status != PeriodStatus::Open {
1880            return Err(stateset_core::CommerceError::ValidationError(
1881                "Period must be open to close".to_string(),
1882            ));
1883        }
1884
1885        // Generate income statement for the period
1886        let income_statement = self.get_income_statement(period.start_date, period.end_date)?;
1887
1888        // Only create closing entry if there's income-statement activity.
1889        if income_statement.net_income == Decimal::ZERO
1890            && income_statement.revenue_lines.iter().all(|l| l.amount == Decimal::ZERO)
1891            && income_statement.expense_lines.iter().all(|l| l.amount == Decimal::ZERO)
1892        {
1893            return Err(stateset_core::CommerceError::ValidationError(
1894                "No net income to close".to_string(),
1895            ));
1896        }
1897
1898        // Get retained earnings account
1899        let retained_earnings = self
1900            .list_accounts(GlAccountFilter {
1901                account_sub_type: Some(AccountSubType::RetainedEarnings),
1902                ..Default::default()
1903            })?
1904            .into_iter()
1905            .next()
1906            .ok_or_else(|| {
1907                stateset_core::CommerceError::ValidationError(
1908                    "Retained earnings account not found".to_string(),
1909                )
1910            })?;
1911
1912        // Create closing entry - debit revenue accounts, credit expense accounts
1913        // and net to retained earnings
1914        let mut lines = Vec::new();
1915
1916        for rev in income_statement.revenue_lines {
1917            // Revenue is credit-normal: a positive balance closes with a
1918            // debit; a contra-normal (negative) balance closes with a credit.
1919            let memo = Some(format!("Close {} to Retained Earnings", rev.account_name));
1920            if rev.amount > Decimal::ZERO {
1921                lines.push(stateset_core::CreateJournalEntryLine::debit(
1922                    rev.account_id,
1923                    rev.amount,
1924                    memo,
1925                ));
1926            } else if rev.amount < Decimal::ZERO {
1927                lines.push(stateset_core::CreateJournalEntryLine::credit(
1928                    rev.account_id,
1929                    rev.amount.abs(),
1930                    memo,
1931                ));
1932            }
1933        }
1934
1935        for exp in income_statement.expense_lines {
1936            // Expenses are debit-normal: a positive balance closes with a
1937            // credit; a contra-normal balance (e.g. a net FX gain sitting on
1938            // an expense-type gain/loss account) closes with a debit.
1939            let memo = Some(format!("Close {} to Retained Earnings", exp.account_name));
1940            if exp.amount > Decimal::ZERO {
1941                lines.push(stateset_core::CreateJournalEntryLine::credit(
1942                    exp.account_id,
1943                    exp.amount,
1944                    memo,
1945                ));
1946            } else if exp.amount < Decimal::ZERO {
1947                lines.push(stateset_core::CreateJournalEntryLine::debit(
1948                    exp.account_id,
1949                    exp.amount.abs(),
1950                    memo,
1951                ));
1952            }
1953        }
1954
1955        // Net to retained earnings (omitted when revenue and expenses offset
1956        // exactly — the closing lines already balance).
1957        if income_statement.net_income > Decimal::ZERO {
1958            lines.push(stateset_core::CreateJournalEntryLine::credit(
1959                retained_earnings.id,
1960                income_statement.net_income,
1961                Some("Net income to Retained Earnings".to_string()),
1962            ));
1963        } else if income_statement.net_income < Decimal::ZERO {
1964            lines.push(stateset_core::CreateJournalEntryLine::debit(
1965                retained_earnings.id,
1966                income_statement.net_income.abs(),
1967                Some("Net loss to Retained Earnings".to_string()),
1968            ));
1969        }
1970
1971        let closing_entry = self.create_journal_entry(stateset_core::CreateJournalEntry {
1972            entry_date: period.end_date,
1973            entry_type: Some(JournalEntryType::Closing),
1974            description: format!("Closing entries for {}", period.period_name),
1975            lines,
1976            source_document_type: Some("period_close".to_string()),
1977            source_document_id: Some(period_id),
1978            auto_post: Some(true),
1979        })?;
1980
1981        // Close the period
1982        self.close_period(period_id, closed_by)?;
1983
1984        Ok(closing_entry)
1985    }
1986
1987    // ========================================================================
1988    // FX Revaluation
1989    // ========================================================================
1990
1991    fn revalue(
1992        &self,
1993        as_of_date: NaiveDate,
1994        base_currency: Option<Currency>,
1995    ) -> Result<RevaluationResult> {
1996        let base = match base_currency {
1997            Some(base) => base,
1998            None => {
1999                let conn = self
2000                    .pool
2001                    .get()
2002                    .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
2003                match conn.query_row(
2004                    "SELECT base_currency FROM store_currency_settings LIMIT 1",
2005                    [],
2006                    |row| row.get::<_, String>(0),
2007                ) {
2008                    Ok(code) => code.parse::<Currency>().map_err(|e| {
2009                        stateset_core::CommerceError::DatabaseError(format!(
2010                            "Invalid store base currency {code:?}: {e}"
2011                        ))
2012                    })?,
2013                    Err(rusqlite::Error::QueryReturnedNoRows) => Currency::default(),
2014                    Err(e) => return Err(map_db_error(e)),
2015                }
2016            }
2017        };
2018        let base_code: stateset_core::CurrencyCode = base.code().parse().map_err(|e| {
2019            stateset_core::CommerceError::ValidationError(format!(
2020                "Base currency {} is not a valid ISO code: {e}",
2021                base.code()
2022            ))
2023        })?;
2024        let base_places = u32::from(base.decimal_places());
2025
2026        let accounts = self.list_accounts(GlAccountFilter {
2027            status: Some(AccountStatus::Active),
2028            is_posting: Some(true),
2029            ..Default::default()
2030        })?;
2031
2032        let mut lines: Vec<RevaluationLine> = Vec::new();
2033        {
2034            let conn = self
2035                .pool
2036                .get()
2037                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
2038
2039            for account in accounts {
2040                if account.currency == base_code {
2041                    continue;
2042                }
2043
2044                // Outstanding foreign-currency balance: posted lines excluding
2045                // prior base-currency FX revaluation adjustments.
2046                let (debits, credits): (String, String) = conn
2047                    .query_row(
2048                        "SELECT decimal_sum(l.debit_amount), decimal_sum(l.credit_amount)
2049                         FROM gl_journal_entry_lines l
2050                         JOIN gl_journal_entries je ON l.journal_entry_id = je.id
2051                         WHERE l.account_id = ?1 AND je.status = 'posted'
2052                           AND (l.reference_type IS NULL OR l.reference_type != ?2)",
2053                        params![account.id.to_string(), FX_REVALUATION_REFERENCE],
2054                        |row| Ok((row.get(0)?, row.get(1)?)),
2055                    )
2056                    .map_err(map_db_error)?;
2057                let debits = parse_decimal_required(debits, 0).map_err(map_db_error)?;
2058                let credits = parse_decimal_required(credits, 1).map_err(map_db_error)?;
2059                let foreign_balance = account.balance_effect(debits, credits);
2060
2061                if foreign_balance.is_zero() && account.current_balance.is_zero() {
2062                    continue;
2063                }
2064
2065                let rate =
2066                    Self::lookup_rate_with_conn(&conn, account.currency.as_str(), base.code())?
2067                        .ok_or_else(|| {
2068                            stateset_core::CommerceError::ValidationError(format!(
2069                                "No exchange rate available for {} -> {}",
2070                                account.currency,
2071                                base.code()
2072                            ))
2073                        })?;
2074
2075                lines.push(stateset_core::compute_revaluation_line(
2076                    &account,
2077                    foreign_balance,
2078                    rate,
2079                    base_places,
2080                ));
2081            }
2082        }
2083
2084        let total_unrealized_gain_loss: Decimal =
2085            lines.iter().map(|l| l.unrealized_gain_loss).sum();
2086
2087        let journal_entry = if lines.iter().any(|l| !l.adjustment.is_zero()) {
2088            let fx_account_id = self.resolve_fx_gain_loss_account()?;
2089            let entry_lines = stateset_core::build_revaluation_journal_lines(&lines, fx_account_id);
2090            Some(self.create_journal_entry(stateset_core::CreateJournalEntry {
2091                entry_date: as_of_date,
2092                entry_type: Some(JournalEntryType::Adjusting),
2093                description: format!("FX revaluation as of {as_of_date}"),
2094                lines: entry_lines,
2095                source_document_type: Some(FX_REVALUATION_REFERENCE.to_string()),
2096                source_document_id: None,
2097                auto_post: Some(true),
2098            })?)
2099        } else {
2100            None
2101        };
2102
2103        Ok(RevaluationResult {
2104            as_of_date,
2105            base_currency: base_code,
2106            total_unrealized_gain_loss,
2107            lines,
2108            journal_entry,
2109        })
2110    }
2111
2112    // ========================================================================
2113    // Batch Operations
2114    // ========================================================================
2115
2116    fn create_accounts_batch(
2117        &self,
2118        inputs: Vec<CreateGlAccount>,
2119    ) -> Result<BatchResult<GlAccount>> {
2120        let mut result = BatchResult::new();
2121
2122        for (index, input) in inputs.into_iter().enumerate() {
2123            match self.create_account(input) {
2124                Ok(account) => result.record_success(account),
2125                Err(e) => result.record_failure(index, None, &e),
2126            }
2127        }
2128
2129        Ok(result)
2130    }
2131
2132    fn get_accounts_batch(&self, ids: Vec<Uuid>) -> Result<Vec<GlAccount>> {
2133        let mut accounts = Vec::new();
2134        for id in ids {
2135            if let Some(account) = self.get_account(id)? {
2136                accounts.push(account);
2137            }
2138        }
2139        Ok(accounts)
2140    }
2141}
2142
2143#[cfg(test)]
2144mod tests {
2145    use super::*;
2146    use crate::SqliteDatabase;
2147    use chrono::NaiveDate;
2148    use rust_decimal_macros::dec;
2149    use stateset_core::{
2150        AccountType, CommerceError, CreateGlAccount, CreateGlPeriod, CreateJournalEntry,
2151        CreateJournalEntryLine, GeneralLedgerRepository, GlAccountFilter, GlPeriodFilter,
2152        JournalEntryFilter, JournalEntryStatus,
2153    };
2154
2155    fn fresh_repo() -> SqliteGeneralLedgerRepository {
2156        SqliteDatabase::in_memory().expect("in-memory").general_ledger()
2157    }
2158
2159    fn make_account(repo: &SqliteGeneralLedgerRepository, num: &str, ty: AccountType) -> GlAccount {
2160        repo.create_account(CreateGlAccount {
2161            account_number: num.into(),
2162            name: format!("Account {num}"),
2163            description: None,
2164            account_type: ty,
2165            account_sub_type: None,
2166            parent_account_id: None,
2167            is_header: Some(false),
2168            is_posting: Some(true),
2169            currency: None,
2170        })
2171        .expect("create account")
2172    }
2173
2174    fn fy_period(repo: &SqliteGeneralLedgerRepository) -> GlPeriod {
2175        let p = repo
2176            .create_period(CreateGlPeriod {
2177                period_name: "FY2026-01".into(),
2178                fiscal_year: 2026,
2179                period_number: 1,
2180                start_date: NaiveDate::from_ymd_opt(2026, 1, 1).expect("date"),
2181                end_date: NaiveDate::from_ymd_opt(2026, 1, 31).expect("date"),
2182            })
2183            .expect("create period");
2184        repo.open_period(p.id).expect("open period")
2185    }
2186
2187    #[test]
2188    fn create_account_persists_and_round_trips() {
2189        let repo = fresh_repo();
2190        let acct = make_account(&repo, "1000", AccountType::Asset);
2191        assert_eq!(acct.account_number, "1000");
2192        let by_id = repo.get_account(acct.id).expect("ok").expect("found");
2193        assert_eq!(by_id.account_number, "1000");
2194        let by_num = repo.get_account_by_number("1000").expect("ok").expect("found");
2195        assert_eq!(by_num.id, acct.id);
2196        assert!(repo.get_account_by_number("missing").expect("ok").is_none());
2197    }
2198
2199    #[test]
2200    fn list_accounts_filters_by_type() {
2201        let repo = fresh_repo();
2202        make_account(&repo, "1000", AccountType::Asset);
2203        make_account(&repo, "1100", AccountType::Asset);
2204        make_account(&repo, "2000", AccountType::Liability);
2205
2206        let assets = repo
2207            .list_accounts(GlAccountFilter {
2208                account_type: Some(AccountType::Asset),
2209                ..Default::default()
2210            })
2211            .expect("list");
2212        assert_eq!(assets.len(), 2);
2213        assert!(assets.iter().all(|a| a.account_type == AccountType::Asset));
2214    }
2215
2216    #[test]
2217    fn create_period_round_trips() {
2218        let repo = fresh_repo();
2219        let period = fy_period(&repo);
2220        let by_id = repo.get_period(period.id).expect("ok").expect("found");
2221        assert_eq!(by_id.period_name, "FY2026-01");
2222
2223        let for_date = repo
2224            .get_period_for_date(NaiveDate::from_ymd_opt(2026, 1, 15).expect("date"))
2225            .expect("ok")
2226            .expect("found");
2227        assert_eq!(for_date.id, period.id);
2228
2229        let listed = repo
2230            .list_periods(GlPeriodFilter { fiscal_year: Some(2026), ..Default::default() })
2231            .expect("list");
2232        assert_eq!(listed.len(), 1);
2233    }
2234
2235    #[test]
2236    fn create_journal_entry_starts_in_draft() {
2237        let repo = fresh_repo();
2238        let _period = fy_period(&repo);
2239        let cash = make_account(&repo, "1000", AccountType::Asset);
2240        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2241
2242        let entry = repo
2243            .create_journal_entry(CreateJournalEntry {
2244                entry_date: NaiveDate::from_ymd_opt(2026, 1, 10).expect("date"),
2245                entry_type: None,
2246                description: "Sale".into(),
2247                lines: vec![
2248                    CreateJournalEntryLine::debit(cash.id, dec!(100), None),
2249                    CreateJournalEntryLine::credit(revenue.id, dec!(100), None),
2250                ],
2251                source_document_type: None,
2252                source_document_id: None,
2253                auto_post: Some(false),
2254            })
2255            .expect("create");
2256
2257        assert_eq!(entry.status, JournalEntryStatus::Draft);
2258        assert!(entry.is_balanced);
2259        assert_eq!(entry.total_debits, dec!(100));
2260        assert_eq!(entry.total_credits, dec!(100));
2261    }
2262
2263    #[test]
2264    fn create_journal_entry_rejects_mixed_debit_credit_line() {
2265        let repo = fresh_repo();
2266        let _period = fy_period(&repo);
2267        let cash = make_account(&repo, "1000", AccountType::Asset);
2268        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2269
2270        let err = repo
2271            .create_journal_entry(CreateJournalEntry {
2272                entry_date: NaiveDate::from_ymd_opt(2026, 1, 10).expect("date"),
2273                entry_type: None,
2274                description: "Bad line".into(),
2275                lines: vec![CreateJournalEntryLine {
2276                    account_id: cash.id,
2277                    description: None,
2278                    debit_amount: dec!(50),
2279                    credit_amount: dec!(50),
2280                    reference_type: None,
2281                    reference_id: None,
2282                }],
2283                source_document_type: None,
2284                source_document_id: Some(revenue.id),
2285                auto_post: None,
2286            })
2287            .expect_err("err");
2288        assert!(matches!(err, CommerceError::ValidationError(_)));
2289    }
2290
2291    #[test]
2292    fn create_journal_entry_without_period_returns_validation_error() {
2293        let repo = fresh_repo();
2294        let cash = make_account(&repo, "1000", AccountType::Asset);
2295        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2296
2297        let err = repo
2298            .create_journal_entry(CreateJournalEntry {
2299                entry_date: NaiveDate::from_ymd_opt(2099, 1, 1).expect("date"),
2300                entry_type: None,
2301                description: "no period".into(),
2302                lines: vec![
2303                    CreateJournalEntryLine::debit(cash.id, dec!(1), None),
2304                    CreateJournalEntryLine::credit(revenue.id, dec!(1), None),
2305                ],
2306                source_document_type: None,
2307                source_document_id: None,
2308                auto_post: None,
2309            })
2310            .expect_err("err");
2311        assert!(matches!(err, CommerceError::ValidationError(_)));
2312    }
2313
2314    #[test]
2315    fn post_journal_entry_marks_posted() {
2316        let repo = fresh_repo();
2317        let _period = fy_period(&repo);
2318        let cash = make_account(&repo, "1000", AccountType::Asset);
2319        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2320
2321        let entry = repo
2322            .create_journal_entry(CreateJournalEntry {
2323                entry_date: NaiveDate::from_ymd_opt(2026, 1, 10).expect("date"),
2324                entry_type: None,
2325                description: "Sale".into(),
2326                lines: vec![
2327                    CreateJournalEntryLine::debit(cash.id, dec!(50), None),
2328                    CreateJournalEntryLine::credit(revenue.id, dec!(50), None),
2329                ],
2330                source_document_type: None,
2331                source_document_id: None,
2332                auto_post: Some(false),
2333            })
2334            .expect("create");
2335
2336        let posted = repo.post_journal_entry(entry.id, "tester").expect("post");
2337        assert_eq!(posted.status, JournalEntryStatus::Posted);
2338    }
2339
2340    #[test]
2341    fn list_journal_entries_filters_by_status() {
2342        let repo = fresh_repo();
2343        let _period = fy_period(&repo);
2344        let cash = make_account(&repo, "1000", AccountType::Asset);
2345        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2346
2347        let draft = repo
2348            .create_journal_entry(CreateJournalEntry {
2349                entry_date: NaiveDate::from_ymd_opt(2026, 1, 5).expect("date"),
2350                entry_type: None,
2351                description: "Draft entry".into(),
2352                lines: vec![
2353                    CreateJournalEntryLine::debit(cash.id, dec!(10), None),
2354                    CreateJournalEntryLine::credit(revenue.id, dec!(10), None),
2355                ],
2356                source_document_type: None,
2357                source_document_id: None,
2358                auto_post: Some(false),
2359            })
2360            .expect("draft");
2361
2362        let to_post = repo
2363            .create_journal_entry(CreateJournalEntry {
2364                entry_date: NaiveDate::from_ymd_opt(2026, 1, 6).expect("date"),
2365                entry_type: None,
2366                description: "Posted entry".into(),
2367                lines: vec![
2368                    CreateJournalEntryLine::debit(cash.id, dec!(20), None),
2369                    CreateJournalEntryLine::credit(revenue.id, dec!(20), None),
2370                ],
2371                source_document_type: None,
2372                source_document_id: None,
2373                auto_post: Some(false),
2374            })
2375            .expect("post");
2376        repo.post_journal_entry(to_post.id, "tester").expect("post");
2377
2378        let drafts = repo
2379            .list_journal_entries(JournalEntryFilter {
2380                status: Some(JournalEntryStatus::Draft),
2381                ..Default::default()
2382            })
2383            .expect("list");
2384        let posted = repo
2385            .list_journal_entries(JournalEntryFilter {
2386                status: Some(JournalEntryStatus::Posted),
2387                ..Default::default()
2388            })
2389            .expect("list");
2390
2391        assert!(drafts.iter().any(|e| e.id == draft.id));
2392        assert!(posted.iter().any(|e| e.id == to_post.id));
2393    }
2394
2395    #[test]
2396    fn close_period_changes_status() {
2397        let repo = fresh_repo();
2398        let period = fy_period(&repo); // already open
2399        assert_eq!(period.status, stateset_core::PeriodStatus::Open);
2400        let closed = repo.close_period(period.id, "tester").expect("close");
2401        assert_eq!(closed.status, stateset_core::PeriodStatus::Closed);
2402    }
2403
2404    #[test]
2405    fn open_period_transitions_future_to_open() {
2406        let repo = fresh_repo();
2407        let raw = repo
2408            .create_period(CreateGlPeriod {
2409                period_name: "FY2026-02".into(),
2410                fiscal_year: 2026,
2411                period_number: 2,
2412                start_date: NaiveDate::from_ymd_opt(2026, 2, 1).expect("date"),
2413                end_date: NaiveDate::from_ymd_opt(2026, 2, 28).expect("date"),
2414            })
2415            .expect("create period");
2416        assert_eq!(raw.status, stateset_core::PeriodStatus::Future);
2417        let opened = repo.open_period(raw.id).expect("open");
2418        assert_eq!(opened.status, stateset_core::PeriodStatus::Open);
2419    }
2420
2421    #[test]
2422    fn create_journal_entry_in_future_period_returns_error() {
2423        let repo = fresh_repo();
2424        repo.create_period(CreateGlPeriod {
2425            period_name: "FY2026-01".into(),
2426            fiscal_year: 2026,
2427            period_number: 1,
2428            start_date: NaiveDate::from_ymd_opt(2026, 1, 1).expect("date"),
2429            end_date: NaiveDate::from_ymd_opt(2026, 1, 31).expect("date"),
2430        })
2431        .expect("create period");
2432        let cash = make_account(&repo, "1000", AccountType::Asset);
2433        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2434
2435        let err = repo
2436            .create_journal_entry(CreateJournalEntry {
2437                entry_date: NaiveDate::from_ymd_opt(2026, 1, 5).expect("date"),
2438                entry_type: None,
2439                description: "won't post".into(),
2440                lines: vec![
2441                    CreateJournalEntryLine::debit(cash.id, dec!(1), None),
2442                    CreateJournalEntryLine::credit(revenue.id, dec!(1), None),
2443                ],
2444                source_document_type: None,
2445                source_document_id: None,
2446                auto_post: None,
2447            })
2448            .expect_err("err");
2449        assert!(matches!(err, CommerceError::ValidationError(_)));
2450    }
2451
2452    fn make_balanced_entry(
2453        repo: &SqliteGeneralLedgerRepository,
2454        debit_account: &GlAccount,
2455        credit_account: &GlAccount,
2456        amount: rust_decimal::Decimal,
2457    ) -> JournalEntry {
2458        repo.create_journal_entry(CreateJournalEntry {
2459            entry_date: NaiveDate::from_ymd_opt(2026, 1, 10).expect("date"),
2460            entry_type: None,
2461            description: "Concurrency test entry".into(),
2462            lines: vec![
2463                CreateJournalEntryLine::debit(debit_account.id, amount, None),
2464                CreateJournalEntryLine::credit(credit_account.id, amount, None),
2465            ],
2466            source_document_type: None,
2467            source_document_id: None,
2468            auto_post: Some(false),
2469        })
2470        .expect("create entry")
2471    }
2472
2473    fn account_balance(repo: &SqliteGeneralLedgerRepository, id: Uuid) -> rust_decimal::Decimal {
2474        repo.get_account(id).expect("ok").expect("found").current_balance
2475    }
2476
2477    #[test]
2478    fn post_journal_entry_concurrent_posts_apply_balances_once() {
2479        use std::sync::{Arc, Barrier};
2480        use std::thread;
2481
2482        let db = Arc::new(SqliteDatabase::in_memory().expect("in-memory"));
2483        let repo = db.general_ledger();
2484        let _period = fy_period(&repo);
2485        let cash = make_account(&repo, "1000", AccountType::Asset);
2486        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2487        let entry = make_balanced_entry(&repo, &cash, &revenue, dec!(100));
2488
2489        let thread_count = 10;
2490        let barrier = Arc::new(Barrier::new(thread_count));
2491        let mut handles = Vec::new();
2492        for _ in 0..thread_count {
2493            let db = Arc::clone(&db);
2494            let barrier = Arc::clone(&barrier);
2495            let entry_id = entry.id;
2496            handles.push(thread::spawn(move || {
2497                let repo = db.general_ledger();
2498                barrier.wait();
2499                repo.post_journal_entry(entry_id, "racer")
2500            }));
2501        }
2502
2503        let results: Vec<_> = handles.into_iter().map(|h| h.join().expect("thread")).collect();
2504        let successes = results.iter().filter(|r| r.is_ok()).count();
2505        // Safety invariant: the entry can be posted AT MOST once, so its lines
2506        // are applied to the ledger at most once. Under extreme lock contention
2507        // the sole winner can fail transiently (retryable), so zero successes is
2508        // acceptable; two or more is the double-post bug this guards against.
2509        assert!(successes <= 1, "journal entry posted more than once: {results:?}");
2510
2511        let mult = Decimal::from(successes as u64);
2512        assert_eq!(
2513            account_balance(&repo, cash.id),
2514            dec!(100) * mult,
2515            "cash must reflect exactly the successful post"
2516        );
2517        assert_eq!(
2518            account_balance(&repo, revenue.id),
2519            dec!(100) * mult,
2520            "revenue must reflect exactly the successful post"
2521        );
2522
2523        let posted = repo.get_journal_entry(entry.id).expect("ok").expect("found");
2524        let expected_status =
2525            if successes == 1 { JournalEntryStatus::Posted } else { JournalEntryStatus::Draft };
2526        assert_eq!(posted.status, expected_status);
2527    }
2528
2529    #[test]
2530    fn post_journal_entry_rejects_already_posted_and_keeps_balances() {
2531        let repo = fresh_repo();
2532        let _period = fy_period(&repo);
2533        let cash = make_account(&repo, "1000", AccountType::Asset);
2534        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2535        let entry = make_balanced_entry(&repo, &cash, &revenue, dec!(40));
2536
2537        repo.post_journal_entry(entry.id, "tester").expect("first post");
2538        assert_eq!(account_balance(&repo, cash.id), dec!(40));
2539
2540        let err = repo.post_journal_entry(entry.id, "again").expect_err("second post must fail");
2541        assert!(
2542            matches!(err, CommerceError::ValidationError(_) | CommerceError::Conflict(_)),
2543            "got {err:?}"
2544        );
2545
2546        assert_eq!(account_balance(&repo, cash.id), dec!(40));
2547        assert_eq!(account_balance(&repo, revenue.id), dec!(40));
2548    }
2549
2550    #[test]
2551    fn void_journal_entry_rejects_already_voided_and_keeps_balances() {
2552        let repo = fresh_repo();
2553        let _period = fy_period(&repo);
2554        let cash = make_account(&repo, "1000", AccountType::Asset);
2555        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2556        let entry = make_balanced_entry(&repo, &cash, &revenue, dec!(25));
2557
2558        repo.post_journal_entry(entry.id, "tester").expect("post");
2559        repo.void_journal_entry(entry.id).expect("first void");
2560        assert_eq!(account_balance(&repo, cash.id), dec!(0));
2561
2562        let err = repo.void_journal_entry(entry.id).expect_err("second void must fail");
2563        assert!(
2564            matches!(err, CommerceError::ValidationError(_) | CommerceError::Conflict(_)),
2565            "got {err:?}"
2566        );
2567
2568        assert_eq!(account_balance(&repo, cash.id), dec!(0));
2569        assert_eq!(account_balance(&repo, revenue.id), dec!(0));
2570    }
2571
2572    #[test]
2573    fn reverse_journal_entry_rejects_second_reversal_and_keeps_balances() {
2574        let repo = fresh_repo();
2575        let _period = fy_period(&repo);
2576        let cash = make_account(&repo, "1000", AccountType::Asset);
2577        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2578        let entry = make_balanced_entry(&repo, &cash, &revenue, dec!(60));
2579
2580        repo.post_journal_entry(entry.id, "tester").expect("post");
2581        repo.reverse_journal_entry(entry.id, NaiveDate::from_ymd_opt(2026, 1, 15).expect("date"))
2582            .expect("first reversal");
2583        assert_eq!(account_balance(&repo, cash.id), dec!(0));
2584
2585        let err = repo
2586            .reverse_journal_entry(entry.id, NaiveDate::from_ymd_opt(2026, 1, 16).expect("date"))
2587            .expect_err("second reversal must fail");
2588        assert!(
2589            matches!(err, CommerceError::ValidationError(_) | CommerceError::Conflict(_)),
2590            "got {err:?}"
2591        );
2592
2593        assert_eq!(account_balance(&repo, cash.id), dec!(0));
2594        assert_eq!(account_balance(&repo, revenue.id), dec!(0));
2595
2596        let reversals = repo
2597            .list_journal_entries(JournalEntryFilter {
2598                source_document_id: Some(entry.id),
2599                ..Default::default()
2600            })
2601            .expect("list");
2602        assert_eq!(reversals.len(), 1, "only one reversing entry may exist");
2603    }
2604
2605    // ========================================================================
2606    // FX revaluation
2607    // ========================================================================
2608
2609    fn make_currency_account(
2610        repo: &SqliteGeneralLedgerRepository,
2611        num: &str,
2612        ty: AccountType,
2613        currency: stateset_core::CurrencyCode,
2614        sub_type: Option<AccountSubType>,
2615    ) -> GlAccount {
2616        repo.create_account(CreateGlAccount {
2617            account_number: num.into(),
2618            name: format!("Account {num}"),
2619            description: None,
2620            account_type: ty,
2621            account_sub_type: sub_type,
2622            parent_account_id: None,
2623            is_header: Some(false),
2624            is_posting: Some(true),
2625            currency: Some(currency),
2626        })
2627        .expect("create account")
2628    }
2629
2630    fn set_rate(db: &SqliteDatabase, from: Currency, to: Currency, rate: rust_decimal::Decimal) {
2631        use stateset_core::CurrencyRepository;
2632        db.currency()
2633            .set_rate(stateset_core::SetExchangeRate {
2634                base_currency: from,
2635                quote_currency: to,
2636                rate,
2637                source: Some("test".into()),
2638            })
2639            .expect("set rate");
2640    }
2641
2642    #[test]
2643    fn revalue_posts_balanced_gain_entry_for_foreign_account() {
2644        let db = SqliteDatabase::in_memory().expect("in-memory");
2645        let repo = db.general_ledger();
2646        let _period = fy_period(&repo);
2647        let eur_cash = make_currency_account(
2648            &repo,
2649            "1015",
2650            AccountType::Asset,
2651            stateset_core::CurrencyCode::EUR,
2652            None,
2653        );
2654        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2655        // Fallback FX gain/loss account (no auto-posting config set).
2656        let fx = make_currency_account(
2657            &repo,
2658            "7900",
2659            AccountType::Expense,
2660            stateset_core::CurrencyCode::USD,
2661            Some(AccountSubType::OtherExpense),
2662        );
2663
2664        // Book 1000 EUR at parity, then move the rate to 1.10.
2665        set_rate(&db, Currency::EUR, Currency::USD, dec!(1));
2666        let entry = make_balanced_entry(&repo, &eur_cash, &revenue, dec!(1000));
2667        repo.post_journal_entry(entry.id, "tester").expect("post");
2668        set_rate(&db, Currency::EUR, Currency::USD, dec!(1.10));
2669
2670        let result = repo
2671            .revalue(NaiveDate::from_ymd_opt(2026, 1, 31).expect("date"), None)
2672            .expect("revalue");
2673
2674        assert_eq!(result.base_currency, stateset_core::CurrencyCode::USD);
2675        assert_eq!(result.total_unrealized_gain_loss, dec!(100.00));
2676        assert_eq!(result.lines.len(), 1);
2677        let line = &result.lines[0];
2678        assert_eq!(line.account_id, eur_cash.id);
2679        assert_eq!(line.foreign_balance, dec!(1000));
2680        assert_eq!(line.revalued_value, dec!(1100.00));
2681        assert_eq!(line.adjustment, dec!(100.00));
2682
2683        let je = result.journal_entry.expect("journal entry");
2684        assert_eq!(je.status, JournalEntryStatus::Posted);
2685        assert!(je.is_balanced);
2686        assert_eq!(je.total_debits, dec!(100.00));
2687        assert_eq!(je.total_credits, dec!(100.00));
2688        assert!(
2689            je.lines.iter().any(|l| l.account_id == eur_cash.id && l.debit_amount == dec!(100.00))
2690        );
2691        assert!(je.lines.iter().any(|l| l.account_id == fx.id && l.credit_amount == dec!(100.00)));
2692
2693        // Carrying value now reflects the as-of rate.
2694        assert_eq!(account_balance(&repo, eur_cash.id), dec!(1100.00));
2695
2696        // Re-running at the same rate is a no-op (idempotent).
2697        let again = repo
2698            .revalue(NaiveDate::from_ymd_opt(2026, 1, 31).expect("date"), None)
2699            .expect("revalue again");
2700        assert!(again.journal_entry.is_none());
2701        assert_eq!(again.total_unrealized_gain_loss, dec!(0.00));
2702        assert_eq!(account_balance(&repo, eur_cash.id), dec!(1100.00));
2703    }
2704
2705    #[test]
2706    fn revalue_posts_loss_entry_when_rate_drops() {
2707        let db = SqliteDatabase::in_memory().expect("in-memory");
2708        let repo = db.general_ledger();
2709        let _period = fy_period(&repo);
2710        let eur_cash = make_currency_account(
2711            &repo,
2712            "1015",
2713            AccountType::Asset,
2714            stateset_core::CurrencyCode::EUR,
2715            None,
2716        );
2717        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2718        let fx = make_currency_account(
2719            &repo,
2720            "7900",
2721            AccountType::Expense,
2722            stateset_core::CurrencyCode::USD,
2723            Some(AccountSubType::OtherExpense),
2724        );
2725
2726        set_rate(&db, Currency::EUR, Currency::USD, dec!(1));
2727        let entry = make_balanced_entry(&repo, &eur_cash, &revenue, dec!(1000));
2728        repo.post_journal_entry(entry.id, "tester").expect("post");
2729        set_rate(&db, Currency::EUR, Currency::USD, dec!(0.85));
2730
2731        let result = repo
2732            .revalue(NaiveDate::from_ymd_opt(2026, 1, 31).expect("date"), None)
2733            .expect("revalue");
2734
2735        assert_eq!(result.total_unrealized_gain_loss, dec!(-150.00));
2736        let je = result.journal_entry.expect("journal entry");
2737        assert!(je.is_balanced);
2738        assert!(
2739            je.lines.iter().any(|l| l.account_id == eur_cash.id && l.credit_amount == dec!(150.00))
2740        );
2741        assert!(je.lines.iter().any(|l| l.account_id == fx.id && l.debit_amount == dec!(150.00)));
2742        assert_eq!(account_balance(&repo, eur_cash.id), dec!(850.00));
2743    }
2744
2745    #[test]
2746    fn revalue_errors_without_fx_account_or_rate() {
2747        let db = SqliteDatabase::in_memory().expect("in-memory");
2748        let repo = db.general_ledger();
2749        let _period = fy_period(&repo);
2750        let eur_cash = make_currency_account(
2751            &repo,
2752            "1015",
2753            AccountType::Asset,
2754            stateset_core::CurrencyCode::EUR,
2755            None,
2756        );
2757        let revenue = make_account(&repo, "4000", AccountType::Revenue);
2758        let entry = make_balanced_entry(&repo, &eur_cash, &revenue, dec!(100));
2759        repo.post_journal_entry(entry.id, "tester").expect("post");
2760
2761        // No exchange rate configured for EUR -> USD.
2762        let err = repo
2763            .revalue(NaiveDate::from_ymd_opt(2026, 1, 31).expect("date"), None)
2764            .expect_err("missing rate");
2765        assert!(matches!(err, CommerceError::ValidationError(_)));
2766
2767        // Rate configured, but no FX gain/loss account resolvable.
2768        set_rate(&db, Currency::EUR, Currency::USD, dec!(1.10));
2769        let err = repo
2770            .revalue(NaiveDate::from_ymd_opt(2026, 1, 31).expect("date"), None)
2771            .expect_err("missing fx account");
2772        assert!(matches!(err, CommerceError::ValidationError(_)));
2773    }
2774}