Skip to main content

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}