1use 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 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 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 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 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 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 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 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 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 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 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 for (line_num, line) in input.lines.iter().enumerate() {
784 let line_id = Uuid::new_v4();
785
786 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut stmt = conn
1722 .prepare(
1723 "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 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 let income_statement = self.get_income_statement(period.start_date, period.end_date)?;
1887
1888 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 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 let mut lines = Vec::new();
1915
1916 for rev in income_statement.revenue_lines {
1917 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 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 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 self.close_period(period_id, closed_by)?;
1983
1984 Ok(closing_entry)
1985 }
1986
1987 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 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 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); 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 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 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 let fx = make_currency_account(
2657 &repo,
2658 "7900",
2659 AccountType::Expense,
2660 stateset_core::CurrencyCode::USD,
2661 Some(AccountSubType::OtherExpense),
2662 );
2663
2664 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 assert_eq!(account_balance(&repo, eur_cash.id), dec!(1100.00));
2695
2696 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 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 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}