stateset_embedded/general_ledger.rs
1//! General Ledger operations
2//!
3//! Comprehensive general ledger supporting:
4//! - Chart of accounts management
5//! - Journal entries with double-entry bookkeeping
6//! - Period management (open, close, lock)
7//! - Auto-posting from commerce transactions
8//! - Financial reports (Trial Balance, Balance Sheet, Income Statement)
9//!
10//! # Example
11//!
12//! ```rust,ignore
13//! use stateset_embedded::{Commerce, CreateGlAccount, AccountType};
14//!
15//! let commerce = Commerce::new("./store.db")?;
16//!
17//! // Initialize standard chart of accounts
18//! commerce.general_ledger().initialize_chart_of_accounts()?;
19//!
20//! // Create a custom account
21//! let account = commerce.general_ledger().create_account(CreateGlAccount {
22//! account_number: "6100".into(),
23//! name: "Marketing Expense".into(),
24//! account_type: AccountType::Expense,
25//! ..Default::default()
26//! })?;
27//! # Ok::<(), stateset_embedded::CommerceError>(())
28//! ```
29
30use chrono::{Months, NaiveDate};
31use rust_decimal::Decimal;
32use stateset_core::{
33 AccountStatus, AutoPostingConfig, BalanceSheet, BatchResult, CloseMonthOptions,
34 CloseMonthReport, CloseMonthStepReport, CloseMonthStepStatus, CommerceError,
35 CreateAutoPostingConfig, CreateGlAccount, CreateGlPeriod, CreateJournalEntry, Currency,
36 CurrencyCode, DepreciationEntryStatus, FixedAssetFilter, FixedAssetStatus, GlAccount,
37 GlAccountFilter, GlPeriod, GlPeriodFilter, IncomeStatement, JournalEntry, JournalEntryFilter,
38 JournalEntryLine, Result, RevaluationResult, RevenueContractFilter, RevenueContractStatus,
39 RevenueEntryStatus, TrialBalance, UpdateGlAccount,
40};
41use stateset_db::{Database, DatabaseCapability};
42use std::sync::Arc;
43use uuid::Uuid;
44
45#[cfg(feature = "events")]
46use crate::events::EventSystem;
47#[cfg(feature = "events")]
48use chrono::Utc;
49#[cfg(feature = "events")]
50use stateset_core::CommerceEvent;
51
52/// General Ledger interface.
53pub struct GeneralLedger {
54 db: Arc<dyn Database>,
55 #[cfg(feature = "events")]
56 event_system: Arc<EventSystem>,
57}
58
59impl std::fmt::Debug for GeneralLedger {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.debug_struct("GeneralLedger").finish_non_exhaustive()
62 }
63}
64
65impl GeneralLedger {
66 #[cfg(feature = "events")]
67 pub(crate) fn new(db: Arc<dyn Database>, event_system: Arc<EventSystem>) -> Self {
68 Self { db, event_system }
69 }
70
71 #[cfg(not(feature = "events"))]
72 pub(crate) fn new(db: Arc<dyn Database>) -> Self {
73 Self { db }
74 }
75
76 #[cfg(feature = "events")]
77 fn emit(&self, event: CommerceEvent) {
78 self.event_system.emit(event);
79 }
80
81 // ========================================================================
82 // Chart of Accounts
83 // ========================================================================
84
85 /// Create a new GL account.
86 ///
87 /// # Example
88 ///
89 /// ```rust,ignore
90 /// use stateset_embedded::{Commerce, CreateGlAccount, AccountType, AccountSubType, CurrencyCode};
91 ///
92 /// let commerce = Commerce::new(":memory:")?;
93 ///
94 /// let account = commerce.general_ledger().create_account(CreateGlAccount {
95 /// account_number: "1000".into(),
96 /// name: "Cash".into(),
97 /// description: Some("Operating cash account".into()),
98 /// account_type: AccountType::Asset,
99 /// account_sub_type: Some(AccountSubType::Cash),
100 /// is_posting: Some(true),
101 /// currency: Some(CurrencyCode::USD),
102 /// ..Default::default()
103 /// })?;
104 /// # Ok::<(), stateset_embedded::CommerceError>(())
105 /// ```
106 pub fn create_account(&self, input: CreateGlAccount) -> Result<GlAccount> {
107 self.db.general_ledger().create_account(input)
108 }
109
110 /// Get a GL account by ID.
111 pub fn get_account(&self, id: Uuid) -> Result<Option<GlAccount>> {
112 self.db.general_ledger().get_account(id)
113 }
114
115 /// Get a GL account by account number.
116 pub fn get_account_by_number(&self, account_number: &str) -> Result<Option<GlAccount>> {
117 self.db.general_ledger().get_account_by_number(account_number)
118 }
119
120 /// Update a GL account.
121 pub fn update_account(&self, id: Uuid, input: UpdateGlAccount) -> Result<GlAccount> {
122 self.db.general_ledger().update_account(id, input)
123 }
124
125 /// List GL accounts with filtering.
126 pub fn list_accounts(&self, filter: GlAccountFilter) -> Result<Vec<GlAccount>> {
127 self.db.general_ledger().list_accounts(filter)
128 }
129
130 /// Get the full account hierarchy.
131 pub fn get_account_hierarchy(&self) -> Result<Vec<GlAccount>> {
132 self.db.general_ledger().get_account_hierarchy()
133 }
134
135 /// Delete a GL account (only if no transactions).
136 pub fn delete_account(&self, id: Uuid) -> Result<()> {
137 self.db.general_ledger().delete_account(id)
138 }
139
140 /// Initialize the standard chart of accounts.
141 ///
142 /// Creates a default set of accounts for Assets, Liabilities, Equity,
143 /// Revenue, and Expenses.
144 ///
145 /// # Example
146 ///
147 /// ```rust,ignore
148 /// use stateset_embedded::Commerce;
149 ///
150 /// let commerce = Commerce::new(":memory:")?;
151 ///
152 /// let accounts = commerce.general_ledger().initialize_chart_of_accounts()?;
153 /// println!("Created {} standard accounts", accounts.len());
154 /// # Ok::<(), stateset_embedded::CommerceError>(())
155 /// ```
156 pub fn initialize_chart_of_accounts(&self) -> Result<Vec<GlAccount>> {
157 self.db.general_ledger().initialize_chart_of_accounts()
158 }
159
160 // ========================================================================
161 // Accounting Periods
162 // ========================================================================
163
164 /// Create a new accounting period.
165 ///
166 /// # Example
167 ///
168 /// ```rust,ignore
169 /// use stateset_embedded::{Commerce, CreateGlPeriod};
170 /// use chrono::NaiveDate;
171 ///
172 /// let commerce = Commerce::new(":memory:")?;
173 ///
174 /// let period = commerce.general_ledger().create_period(CreateGlPeriod {
175 /// period_name: "January 2025".into(),
176 /// fiscal_year: 2025,
177 /// period_number: 1,
178 /// start_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
179 /// end_date: NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
180 /// })?;
181 /// # Ok::<(), stateset_embedded::CommerceError>(())
182 /// ```
183 pub fn create_period(&self, input: CreateGlPeriod) -> Result<GlPeriod> {
184 self.db.general_ledger().create_period(input)
185 }
186
187 /// Get a period by ID.
188 pub fn get_period(&self, id: Uuid) -> Result<Option<GlPeriod>> {
189 self.db.general_ledger().get_period(id)
190 }
191
192 /// Get the current open period.
193 pub fn get_current_period(&self) -> Result<Option<GlPeriod>> {
194 self.db.general_ledger().get_current_period()
195 }
196
197 /// Get the period for a specific date.
198 pub fn get_period_for_date(&self, date: NaiveDate) -> Result<Option<GlPeriod>> {
199 self.db.general_ledger().get_period_for_date(date)
200 }
201
202 /// List periods with filtering.
203 pub fn list_periods(&self, filter: GlPeriodFilter) -> Result<Vec<GlPeriod>> {
204 self.db.general_ledger().list_periods(filter)
205 }
206
207 /// Open a period (transition from future to open).
208 pub fn open_period(&self, id: Uuid) -> Result<GlPeriod> {
209 self.db.general_ledger().open_period(id)
210 }
211
212 /// Close a period (no more postings allowed except adjustments).
213 pub fn close_period(&self, id: Uuid, closed_by: &str) -> Result<GlPeriod> {
214 self.db.general_ledger().close_period(id, closed_by)
215 }
216
217 /// Lock a period (permanently closed, no changes allowed).
218 pub fn lock_period(&self, id: Uuid, locked_by: &str) -> Result<GlPeriod> {
219 self.db.general_ledger().lock_period(id, locked_by)
220 }
221
222 /// Reopen a closed period (for adjustments).
223 pub fn reopen_period(&self, id: Uuid) -> Result<GlPeriod> {
224 self.db.general_ledger().reopen_period(id)
225 }
226
227 // ========================================================================
228 // Journal Entries
229 // ========================================================================
230
231 /// Create a journal entry.
232 ///
233 /// # Example
234 ///
235 /// ```rust,ignore
236 /// use stateset_embedded::{Commerce, CreateJournalEntry, CreateJournalEntryLine};
237 /// use rust_decimal_macros::dec;
238 /// use chrono::NaiveDate;
239 /// use uuid::Uuid;
240 ///
241 /// let commerce = Commerce::new(":memory:")?;
242 ///
243 /// // Debit Cash, Credit Sales Revenue
244 /// let entry = commerce.general_ledger().create_journal_entry(CreateJournalEntry {
245 /// entry_date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
246 /// description: "Cash sale".into(),
247 /// lines: vec![
248 /// CreateJournalEntryLine::debit(cash_account_id, dec!(100.00), Some("Cash received".into())),
249 /// CreateJournalEntryLine::credit(sales_account_id, dec!(100.00), Some("Sales revenue".into())),
250 /// ],
251 /// auto_post: Some(true),
252 /// ..Default::default()
253 /// })?;
254 /// # Ok::<(), stateset_embedded::CommerceError>(())
255 /// ```
256 pub fn create_journal_entry(&self, input: CreateJournalEntry) -> Result<JournalEntry> {
257 self.db.general_ledger().create_journal_entry(input)
258 }
259
260 /// Get a journal entry by ID.
261 pub fn get_journal_entry(&self, id: Uuid) -> Result<Option<JournalEntry>> {
262 self.db.general_ledger().get_journal_entry(id)
263 }
264
265 /// Get a journal entry by entry number.
266 pub fn get_journal_entry_by_number(&self, number: &str) -> Result<Option<JournalEntry>> {
267 self.db.general_ledger().get_journal_entry_by_number(number)
268 }
269
270 /// List journal entries with filtering.
271 pub fn list_journal_entries(&self, filter: JournalEntryFilter) -> Result<Vec<JournalEntry>> {
272 self.db.general_ledger().list_journal_entries(filter)
273 }
274
275 /// Post a journal entry (update account balances).
276 pub fn post_journal_entry(&self, id: Uuid, posted_by: &str) -> Result<JournalEntry> {
277 self.db.general_ledger().post_journal_entry(id, posted_by)
278 }
279
280 /// Void a posted journal entry.
281 pub fn void_journal_entry(&self, id: Uuid) -> Result<JournalEntry> {
282 self.db.general_ledger().void_journal_entry(id)
283 }
284
285 /// Reverse a journal entry (create an offsetting entry).
286 pub fn reverse_journal_entry(
287 &self,
288 id: Uuid,
289 reversal_date: NaiveDate,
290 ) -> Result<JournalEntry> {
291 self.db.general_ledger().reverse_journal_entry(id, reversal_date)
292 }
293
294 /// Get journal entry lines for an entry.
295 pub fn get_journal_entry_lines(&self, journal_entry_id: Uuid) -> Result<Vec<JournalEntryLine>> {
296 self.db.general_ledger().get_journal_entry_lines(journal_entry_id)
297 }
298
299 // ========================================================================
300 // Auto-Posting Configuration
301 // ========================================================================
302
303 /// Get the current auto-posting configuration.
304 pub fn get_auto_posting_config(&self) -> Result<Option<AutoPostingConfig>> {
305 self.db.general_ledger().get_auto_posting_config()
306 }
307
308 /// Set up auto-posting configuration.
309 ///
310 /// The optional `auto_post_depreciation` and `auto_post_revenue_recognition`
311 /// flags (default off) additionally auto-post journal entries when
312 /// fixed-asset depreciation is posted or deferred revenue is recognized.
313 ///
314 /// # Example
315 ///
316 /// ```rust,ignore
317 /// use stateset_embedded::{Commerce, CreateAutoPostingConfig};
318 /// use uuid::Uuid;
319 ///
320 /// let commerce = Commerce::new(":memory:")?;
321 ///
322 /// // Set up automatic GL postings for commerce transactions
323 /// commerce.general_ledger().set_auto_posting_config(CreateAutoPostingConfig {
324 /// config_name: "Default".into(),
325 /// cash_account_id: cash_id,
326 /// accounts_receivable_account_id: ar_id,
327 /// inventory_account_id: inv_id,
328 /// accounts_payable_account_id: ap_id,
329 /// sales_revenue_account_id: revenue_id,
330 /// cogs_account_id: cogs_id,
331 /// ..Default::default()
332 /// })?;
333 /// # Ok::<(), stateset_embedded::CommerceError>(())
334 /// ```
335 pub fn set_auto_posting_config(
336 &self,
337 input: CreateAutoPostingConfig,
338 ) -> Result<AutoPostingConfig> {
339 self.db.general_ledger().set_auto_posting_config(input)
340 }
341
342 // ========================================================================
343 // Auto-Posting Operations
344 // ========================================================================
345
346 /// Auto-post a customer invoice (debit AR, credit Revenue).
347 pub fn auto_post_invoice(&self, invoice_id: Uuid) -> Result<JournalEntry> {
348 self.db.general_ledger().auto_post_invoice(invoice_id.into())
349 }
350
351 /// Auto-post a payment received (debit Cash, credit AR).
352 pub fn auto_post_payment_received(&self, payment_id: Uuid) -> Result<JournalEntry> {
353 self.db.general_ledger().auto_post_payment_received(payment_id)
354 }
355
356 /// Auto-post a supplier bill (debit Inventory/Expense, credit AP).
357 pub fn auto_post_bill(&self, bill_id: Uuid) -> Result<JournalEntry> {
358 self.db.general_ledger().auto_post_bill(bill_id)
359 }
360
361 /// Auto-post a bill payment (debit AP, credit Cash).
362 pub fn auto_post_bill_payment(&self, payment_id: Uuid) -> Result<JournalEntry> {
363 self.db.general_ledger().auto_post_bill_payment(payment_id)
364 }
365
366 /// Auto-post inventory cost (COGS on sale).
367 pub fn auto_post_inventory_cost(&self, cost_transaction_id: Uuid) -> Result<JournalEntry> {
368 self.db.general_ledger().auto_post_inventory_cost(cost_transaction_id)
369 }
370
371 /// Auto-post a write-off (debit Bad Debt Expense, credit AR).
372 pub fn auto_post_write_off(&self, write_off_id: Uuid) -> Result<JournalEntry> {
373 self.db.general_ledger().auto_post_write_off(write_off_id)
374 }
375
376 // ========================================================================
377 // Financial Reports
378 // ========================================================================
379
380 /// Generate a trial balance.
381 ///
382 /// # Example
383 ///
384 /// ```rust,ignore
385 /// use stateset_embedded::Commerce;
386 /// use chrono::NaiveDate;
387 ///
388 /// let commerce = Commerce::new(":memory:")?;
389 ///
390 /// let trial_balance = commerce.general_ledger().get_trial_balance(
391 /// NaiveDate::from_ymd_opt(2025, 1, 31).unwrap()
392 /// )?;
393 ///
394 /// println!("Trial Balance as of {}", trial_balance.as_of_date);
395 /// println!("Total Debits: ${}", trial_balance.total_debits);
396 /// println!("Total Credits: ${}", trial_balance.total_credits);
397 /// println!("Is Balanced: {}", trial_balance.is_balanced);
398 /// # Ok::<(), stateset_embedded::CommerceError>(())
399 /// ```
400 pub fn get_trial_balance(&self, as_of_date: NaiveDate) -> Result<TrialBalance> {
401 self.db.general_ledger().get_trial_balance(as_of_date)
402 }
403
404 /// Generate a balance sheet.
405 ///
406 /// # Example
407 ///
408 /// ```rust,ignore
409 /// use stateset_embedded::Commerce;
410 /// use chrono::NaiveDate;
411 ///
412 /// let commerce = Commerce::new(":memory:")?;
413 ///
414 /// let balance_sheet = commerce.general_ledger().get_balance_sheet(
415 /// NaiveDate::from_ymd_opt(2025, 1, 31).unwrap()
416 /// )?;
417 ///
418 /// println!("Balance Sheet as of {}", balance_sheet.as_of_date);
419 /// println!("Total Assets: ${}", balance_sheet.total_assets);
420 /// println!("Total Liabilities: ${}", balance_sheet.total_liabilities);
421 /// println!("Total Equity: ${}", balance_sheet.total_equity);
422 /// # Ok::<(), stateset_embedded::CommerceError>(())
423 /// ```
424 pub fn get_balance_sheet(&self, as_of_date: NaiveDate) -> Result<BalanceSheet> {
425 self.db.general_ledger().get_balance_sheet(as_of_date)
426 }
427
428 /// Generate an income statement.
429 ///
430 /// # Example
431 ///
432 /// ```rust,ignore
433 /// use stateset_embedded::Commerce;
434 /// use chrono::NaiveDate;
435 ///
436 /// let commerce = Commerce::new(":memory:")?;
437 ///
438 /// let income_statement = commerce.general_ledger().get_income_statement(
439 /// NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
440 /// NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
441 /// )?;
442 ///
443 /// println!("Income Statement {} to {}", income_statement.period_start, income_statement.period_end);
444 /// println!("Total Revenue: ${}", income_statement.total_revenue);
445 /// println!("Total Expenses: ${}", income_statement.total_expenses);
446 /// println!("Net Income: ${}", income_statement.net_income);
447 /// # Ok::<(), stateset_embedded::CommerceError>(())
448 /// ```
449 pub fn get_income_statement(
450 &self,
451 start_date: NaiveDate,
452 end_date: NaiveDate,
453 ) -> Result<IncomeStatement> {
454 self.db.general_ledger().get_income_statement(start_date, end_date)
455 }
456
457 /// Get the current balance of an account.
458 pub fn get_account_balance(
459 &self,
460 account_id: Uuid,
461 as_of_date: Option<NaiveDate>,
462 ) -> Result<Option<Decimal>> {
463 self.db.general_ledger().get_account_balance(account_id, as_of_date)
464 }
465
466 /// Get all transactions for an account.
467 pub fn get_account_transactions(
468 &self,
469 account_id: Uuid,
470 filter: JournalEntryFilter,
471 ) -> Result<Vec<JournalEntryLine>> {
472 self.db.general_ledger().get_account_transactions(account_id, filter)
473 }
474
475 // ========================================================================
476 // Period Close
477 // ========================================================================
478
479 /// Revalue foreign-currency account balances at the as-of exchange rate.
480 ///
481 /// For every active posting account whose currency differs from the base
482 /// currency, the outstanding foreign-currency balance is revalued at the
483 /// current exchange rate and the net unrealized gain/loss is posted as a
484 /// balanced adjusting journal entry against the configured FX gain/loss
485 /// account.
486 ///
487 /// `base_currency` defaults to the store's configured base currency.
488 pub fn revalue(
489 &self,
490 as_of_date: NaiveDate,
491 base_currency: Option<Currency>,
492 ) -> Result<RevaluationResult> {
493 let result = self.db.general_ledger().revalue(as_of_date, base_currency)?;
494 #[cfg(feature = "events")]
495 if result.journal_entry.is_some() {
496 self.emit(CommerceEvent::FxRevaluationPosted {
497 as_of_date: result.as_of_date,
498 base_currency: result.base_currency,
499 total_unrealized_gain_loss: result.total_unrealized_gain_loss,
500 journal_entry_id: result.journal_entry.as_ref().map(|e| e.id),
501 timestamp: Utc::now(),
502 });
503 }
504 Ok(result)
505 }
506
507 /// Run period close process (generate closing entries, close period).
508 ///
509 /// This will:
510 /// 1. Generate income statement for the period
511 /// 2. Create closing entries to zero out revenue/expense accounts
512 /// 3. Transfer net income to retained earnings
513 /// 4. Close the period
514 pub fn run_period_close(&self, period_id: Uuid, closed_by: &str) -> Result<JournalEntry> {
515 self.db.general_ledger().run_period_close(period_id, closed_by)
516 }
517
518 /// Close the month: orchestrate every period-end step in order.
519 ///
520 /// Runs, composing existing operations:
521 /// 1. Post scheduled depreciation due through period end for all
522 /// in-service assets.
523 /// 2. Recognize deferred revenue through period end for active contracts.
524 /// 3. FX revaluation as of period end — skipped silently when there are
525 /// no foreign-currency accounts or no FX gain/loss account is
526 /// configured (the close never fails on this step).
527 /// 4. [`run_period_close`](Self::run_period_close) (closing entries +
528 /// close period).
529 ///
530 /// With `options.dry_run` the candidates for each step are computed via
531 /// read APIs and reported without writing anything. Per-asset and
532 /// per-obligation failures are collected as warnings on the step report
533 /// and do not abort the close.
534 pub fn close_month(
535 &self,
536 period_id: Uuid,
537 options: CloseMonthOptions,
538 ) -> Result<CloseMonthReport> {
539 let period = self.get_period(period_id)?.ok_or(CommerceError::NotFound)?;
540 let closed_by = options.closed_by.clone().unwrap_or_else(|| "system".to_string());
541
542 let depreciation = if options.skip_depreciation {
543 CloseMonthStepReport::skipped(None)
544 } else {
545 self.close_month_depreciation(&period, options.dry_run)?
546 };
547
548 let revenue_recognition = if options.skip_revenue_recognition {
549 CloseMonthStepReport::skipped(None)
550 } else {
551 self.close_month_revenue_recognition(&period, options.dry_run)?
552 };
553
554 let fx_revaluation = if options.skip_fx_revaluation {
555 CloseMonthStepReport::skipped(None)
556 } else {
557 self.close_month_fx_revaluation(&period, options.dry_run)?
558 };
559
560 let mut closing_entry = None;
561 let period_close = if options.skip_period_close {
562 CloseMonthStepReport::skipped(None)
563 } else if options.dry_run {
564 let statement = self.get_income_statement(period.start_date, period.end_date)?;
565 let has_activity =
566 !statement.total_revenue.is_zero() || !statement.total_expenses.is_zero();
567 CloseMonthStepReport {
568 status: CloseMonthStepStatus::DryRun,
569 entry_count: u64::from(has_activity),
570 total_amount: statement.total_revenue + statement.total_expenses,
571 warnings: Vec::new(),
572 }
573 } else {
574 let entry = self.run_period_close(period_id, &closed_by)?;
575 let total = entry.total_debits;
576 closing_entry = Some(entry);
577 CloseMonthStepReport {
578 status: CloseMonthStepStatus::Executed,
579 entry_count: 1,
580 total_amount: total,
581 warnings: Vec::new(),
582 }
583 };
584
585 let period_status = if options.dry_run || options.skip_period_close {
586 period.status
587 } else {
588 self.get_period(period_id)?.map_or(period.status, |p| p.status)
589 };
590
591 let report = CloseMonthReport {
592 period_id,
593 period_name: period.period_name,
594 dry_run: options.dry_run,
595 depreciation,
596 revenue_recognition,
597 fx_revaluation,
598 period_close,
599 closing_entry,
600 period_status,
601 };
602 #[cfg(feature = "events")]
603 if !report.dry_run {
604 self.emit(CommerceEvent::MonthEndCloseCompleted {
605 period_id: report.period_id,
606 period_name: report.period_name.clone(),
607 depreciation_total: report.depreciation.total_amount,
608 revenue_recognized_total: report.revenue_recognition.total_amount,
609 fx_unrealized_gain_loss: report.fx_revaluation.total_amount,
610 closing_entry_id: report.closing_entry.as_ref().map(|e| e.id),
611 timestamp: Utc::now(),
612 });
613 }
614 Ok(report)
615 }
616
617 /// Step 1: post scheduled depreciation due through period end.
618 ///
619 /// A schedule entry `n` (1-based) is due once `period.end_date` reaches
620 /// `in_service_date + (n - 1) months` — i.e. closing the month the asset
621 /// went into service posts its first depreciation period.
622 fn close_month_depreciation(
623 &self,
624 period: &GlPeriod,
625 dry_run: bool,
626 ) -> Result<CloseMonthStepReport> {
627 if !self.db.supports_capability(DatabaseCapability::FixedAssets) {
628 return Ok(CloseMonthStepReport::skipped(Some(
629 "fixed assets are not supported by the active backend".to_string(),
630 )));
631 }
632 let assets = self.db.fixed_assets().list(FixedAssetFilter {
633 status: Some(FixedAssetStatus::InService),
634 ..Default::default()
635 })?;
636 let mut entry_count = 0u64;
637 let mut total_amount = Decimal::ZERO;
638 let mut warnings = Vec::new();
639 for asset in assets {
640 let Some(in_service) = asset.in_service_date else { continue };
641 let Some(schedule) = self.db.fixed_assets().get_schedule(asset.id)? else {
642 warnings.push(format!(
643 "asset {}: no depreciation schedule generated",
644 asset.asset_number
645 ));
646 continue;
647 };
648 let due: Vec<_> = schedule
649 .entries
650 .iter()
651 .filter(|e| {
652 e.status == DepreciationEntryStatus::Scheduled
653 && e.period >= 1
654 && in_service
655 .checked_add_months(Months::new(e.period - 1))
656 .is_some_and(|d| d <= period.end_date)
657 })
658 .collect();
659 if due.is_empty() {
660 continue;
661 }
662 let amount: Decimal = due.iter().map(|e| e.amount).sum();
663 if dry_run {
664 entry_count += due.len() as u64;
665 total_amount += amount;
666 continue;
667 }
668 match self.db.fixed_assets().post_depreciation(asset.id, due.len() as u32) {
669 Ok(_) => {
670 entry_count += due.len() as u64;
671 total_amount += amount;
672 }
673 Err(e) => warnings.push(format!("asset {}: {e}", asset.asset_number)),
674 }
675 }
676 Ok(CloseMonthStepReport {
677 status: if dry_run {
678 CloseMonthStepStatus::DryRun
679 } else {
680 CloseMonthStepStatus::Executed
681 },
682 entry_count,
683 total_amount,
684 warnings,
685 })
686 }
687
688 /// Step 2: recognize deferred revenue through period end for active
689 /// contracts with generated schedules.
690 fn close_month_revenue_recognition(
691 &self,
692 period: &GlPeriod,
693 dry_run: bool,
694 ) -> Result<CloseMonthStepReport> {
695 if !self.db.supports_capability(DatabaseCapability::RevenueRecognition) {
696 return Ok(CloseMonthStepReport::skipped(Some(
697 "revenue recognition is not supported by the active backend".to_string(),
698 )));
699 }
700 let contracts = self.db.revenue_recognition().list_contracts(RevenueContractFilter {
701 status: Some(RevenueContractStatus::Active),
702 ..Default::default()
703 })?;
704 let mut entry_count = 0u64;
705 let mut total_amount = Decimal::ZERO;
706 let mut warnings = Vec::new();
707 for contract in contracts {
708 for obligation in &contract.obligations {
709 let Some(schedule) = self.db.revenue_recognition().get_schedule(obligation.id)?
710 else {
711 continue;
712 };
713 let due: Vec<_> = schedule
714 .entries
715 .iter()
716 .filter(|e| {
717 e.status == RevenueEntryStatus::Deferred
718 && e.period_start <= period.end_date
719 })
720 .collect();
721 if due.is_empty() {
722 continue;
723 }
724 let amount: Decimal = due.iter().map(|e| e.amount).sum();
725 if dry_run {
726 entry_count += due.len() as u64;
727 total_amount += amount;
728 continue;
729 }
730 match self.db.revenue_recognition().recognize_period(obligation.id, period.end_date)
731 {
732 Ok(_) => {
733 entry_count += due.len() as u64;
734 total_amount += amount;
735 }
736 Err(e) => warnings.push(format!(
737 "contract {} obligation {}: {e}",
738 contract.contract_number, obligation.id
739 )),
740 }
741 }
742 }
743 Ok(CloseMonthStepReport {
744 status: if dry_run {
745 CloseMonthStepStatus::DryRun
746 } else {
747 CloseMonthStepStatus::Executed
748 },
749 entry_count,
750 total_amount,
751 warnings,
752 })
753 }
754
755 /// Step 3: FX revaluation as of period end.
756 ///
757 /// Skipped silently when no active posting account is held in a foreign
758 /// currency. Validation failures inside the revaluation (no FX gain/loss
759 /// account configured, missing exchange rate) also downgrade to a skipped
760 /// step with a warning so the close never fails here.
761 fn close_month_fx_revaluation(
762 &self,
763 period: &GlPeriod,
764 dry_run: bool,
765 ) -> Result<CloseMonthStepReport> {
766 let base: CurrencyCode = {
767 let settings = self.db.currency().get_settings()?;
768 match settings.base_currency.code().parse() {
769 Ok(code) => code,
770 Err(_) => {
771 return Ok(CloseMonthStepReport::skipped(Some(format!(
772 "store base currency {} is not a valid ISO code",
773 settings.base_currency.code()
774 ))));
775 }
776 }
777 };
778 let foreign_accounts: Vec<GlAccount> = self
779 .list_accounts(GlAccountFilter {
780 status: Some(AccountStatus::Active),
781 is_posting: Some(true),
782 ..Default::default()
783 })?
784 .into_iter()
785 .filter(|a| a.currency != base)
786 .collect();
787 if foreign_accounts.is_empty() {
788 return Ok(CloseMonthStepReport::skipped(None));
789 }
790 if dry_run {
791 return Ok(CloseMonthStepReport {
792 status: CloseMonthStepStatus::DryRun,
793 entry_count: foreign_accounts.len() as u64,
794 total_amount: Decimal::ZERO,
795 warnings: Vec::new(),
796 });
797 }
798 match self.revalue(period.end_date, None) {
799 Ok(result) => Ok(CloseMonthStepReport {
800 status: CloseMonthStepStatus::Executed,
801 entry_count: u64::from(result.journal_entry.is_some()),
802 total_amount: result.total_unrealized_gain_loss,
803 warnings: Vec::new(),
804 }),
805 Err(CommerceError::ValidationError(message)) => Ok(CloseMonthStepReport::skipped(
806 Some(format!("fx revaluation skipped: {message}")),
807 )),
808 Err(e) => Err(e),
809 }
810 }
811
812 // ========================================================================
813 // Batch Operations
814 // ========================================================================
815
816 /// Create multiple accounts in batch.
817 pub fn create_accounts_batch(
818 &self,
819 inputs: Vec<CreateGlAccount>,
820 ) -> Result<BatchResult<GlAccount>> {
821 self.db.general_ledger().create_accounts_batch(inputs)
822 }
823
824 /// Get multiple accounts by IDs.
825 pub fn get_accounts_batch(&self, ids: Vec<Uuid>) -> Result<Vec<GlAccount>> {
826 self.db.general_ledger().get_accounts_batch(ids)
827 }
828}