Skip to main content

stateset_core/models/
general_ledger.rs

1//! General Ledger domain models
2//!
3//! Full double-entry accounting system supporting:
4//! - Chart of Accounts with hierarchy
5//! - Journal entries (balanced debits = credits)
6//! - GL periods (open, closed, locked)
7//! - Auto-posting from commerce transactions
8//! - Trial balance and financial statements
9
10use chrono::{DateTime, NaiveDate, Utc};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use stateset_primitives::CurrencyCode;
14use std::fmt;
15use std::str::FromStr;
16use strum::{Display, EnumString};
17use uuid::Uuid;
18
19// ============================================================================
20// Account Type Enums
21// ============================================================================
22
23/// GL Account type (follows standard accounting)
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
25#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
26#[serde(rename_all = "snake_case")]
27#[non_exhaustive]
28pub enum AccountType {
29    /// Cash, receivables, inventory, and other economic resources.
30    Asset,
31    /// Obligations owed to creditors and suppliers.
32    Liability,
33    /// Owner's residual interest in the business.
34    Equity,
35    /// Income earned from sales or services.
36    Revenue,
37    /// Costs incurred in earning revenue.
38    Expense,
39}
40
41impl AccountType {
42    /// Returns the normal balance side for this account type
43    #[must_use]
44    pub const fn normal_balance(&self) -> BalanceSide {
45        match self {
46            Self::Asset | Self::Expense => BalanceSide::Debit,
47            Self::Liability | Self::Equity | Self::Revenue => BalanceSide::Credit,
48        }
49    }
50
51    /// Returns true if this account type appears on the Balance Sheet
52    #[must_use]
53    pub const fn is_balance_sheet(&self) -> bool {
54        matches!(self, Self::Asset | Self::Liability | Self::Equity)
55    }
56
57    /// Returns true if this account type appears on the Income Statement
58    #[must_use]
59    pub const fn is_income_statement(&self) -> bool {
60        matches!(self, Self::Revenue | Self::Expense)
61    }
62}
63
64/// Balance side (debit or credit)
65#[derive(
66    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
67)]
68#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
69#[serde(rename_all = "snake_case")]
70#[non_exhaustive]
71pub enum BalanceSide {
72    /// Increases asset and expense accounts; decreases liabilities, equity, and revenue.
73    #[default]
74    Debit,
75    /// Increases liability, equity, and revenue accounts; decreases assets and expenses.
76    Credit,
77}
78
79/// Account sub-types for more granular classification
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
81#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
82#[serde(rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum AccountSubType {
85    // Assets
86    /// Cash and cash equivalents.
87    Cash,
88    /// Amounts owed by customers for goods or services delivered.
89    AccountsReceivable,
90    /// Goods held for sale or used in production.
91    Inventory,
92    /// Expenses paid in advance not yet recognized as cost.
93    PrepaidExpense,
94    /// Long-lived tangible assets such as equipment and buildings.
95    FixedAsset,
96    /// Contra-asset reducing the carrying value of fixed assets.
97    AccumulatedDepreciation,
98    /// Current assets not classified elsewhere.
99    OtherCurrentAsset,
100    /// Non-current assets not classified elsewhere.
101    OtherNonCurrentAsset,
102    // Liabilities
103    /// Amounts owed to suppliers for goods or services received.
104    AccountsPayable,
105    /// Expenses incurred but not yet paid.
106    AccruedLiabilities,
107    /// Customer deposits or payments for goods/services not yet delivered.
108    UnearnedRevenue,
109    /// Debt due within one year.
110    ShortTermDebt,
111    /// Debt due beyond one year.
112    LongTermDebt,
113    /// Current liabilities not classified elsewhere.
114    OtherCurrentLiability,
115    /// Non-current liabilities not classified elsewhere.
116    OtherNonCurrentLiability,
117    // Equity
118    /// Paid-in capital from shareholders.
119    CommonStock,
120    /// Cumulative earnings retained in the business.
121    RetainedEarnings,
122    /// Equity accounts not classified elsewhere.
123    OtherEquity,
124    // Revenue
125    /// Revenue from product sales.
126    SalesRevenue,
127    /// Revenue from services rendered.
128    ServiceRevenue,
129    /// Revenue not classified elsewhere.
130    OtherRevenue,
131    // Expense
132    /// Direct cost of goods sold to customers.
133    CostOfGoodsSold,
134    /// Recurring expenses related to running the business.
135    OperatingExpense,
136    /// Wages, salaries, and related employee costs.
137    Payroll,
138    /// Costs for leasing office or warehouse space.
139    RentExpense,
140    /// Electricity, water, and similar utility costs.
141    UtilitiesExpense,
142    /// Allocation of fixed asset cost over its useful life.
143    DepreciationExpense,
144    /// Cost of borrowing funds.
145    InterestExpense,
146    /// Income tax and other tax charges.
147    TaxExpense,
148    /// Expenses not classified elsewhere.
149    OtherExpense,
150}
151
152/// Account status
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
154#[serde(rename_all = "snake_case")]
155#[non_exhaustive]
156pub enum AccountStatus {
157    /// Account accepts postings and appears in reports.
158    #[default]
159    Active,
160    /// Account is temporarily disabled; no new postings allowed.
161    Inactive,
162    /// Account is permanently closed and hidden from normal views.
163    Archived,
164}
165
166impl fmt::Display for AccountStatus {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        match self {
169            Self::Active => write!(f, "active"),
170            Self::Inactive => write!(f, "inactive"),
171            Self::Archived => write!(f, "archived"),
172        }
173    }
174}
175
176impl FromStr for AccountStatus {
177    type Err = String;
178    fn from_str(s: &str) -> Result<Self, Self::Err> {
179        match s.to_lowercase().as_str() {
180            "active" => Ok(Self::Active),
181            "inactive" => Ok(Self::Inactive),
182            "archived" => Ok(Self::Archived),
183            _ => Err(format!("Unknown account status: {s}")),
184        }
185    }
186}
187
188// ============================================================================
189// Period & Journal Entry Enums
190// ============================================================================
191
192/// GL Period status
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
194#[serde(rename_all = "snake_case")]
195#[non_exhaustive]
196pub enum PeriodStatus {
197    /// Period has not yet started; posting is not allowed.
198    #[default]
199    Future,
200    /// Period is active and accepts journal entry postings.
201    Open,
202    /// Period has ended; no further postings permitted without re-opening.
203    Closed,
204    /// Period is permanently sealed; cannot be re-opened.
205    Locked,
206}
207
208impl fmt::Display for PeriodStatus {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        match self {
211            Self::Future => write!(f, "future"),
212            Self::Open => write!(f, "open"),
213            Self::Closed => write!(f, "closed"),
214            Self::Locked => write!(f, "locked"),
215        }
216    }
217}
218
219impl FromStr for PeriodStatus {
220    type Err = String;
221    fn from_str(s: &str) -> Result<Self, Self::Err> {
222        match s.to_lowercase().as_str() {
223            "future" => Ok(Self::Future),
224            "open" => Ok(Self::Open),
225            "closed" => Ok(Self::Closed),
226            "locked" => Ok(Self::Locked),
227            _ => Err(format!("Unknown period status: {s}")),
228        }
229    }
230}
231
232/// Journal entry type
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
234#[serde(rename_all = "snake_case")]
235#[non_exhaustive]
236pub enum JournalEntryType {
237    /// Routine transaction entry.
238    #[default]
239    Standard,
240    /// End-of-period accrual or deferral entry.
241    Adjusting,
242    /// Entry to close temporary accounts at period end.
243    Closing,
244    /// Auto-generated entry that reverses a prior adjusting entry.
245    Reversing,
246    /// Entry to establish opening balances for a new period or entity.
247    Opening,
248}
249
250impl fmt::Display for JournalEntryType {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        match self {
253            Self::Standard => write!(f, "standard"),
254            Self::Adjusting => write!(f, "adjusting"),
255            Self::Closing => write!(f, "closing"),
256            Self::Reversing => write!(f, "reversing"),
257            Self::Opening => write!(f, "opening"),
258        }
259    }
260}
261
262impl FromStr for JournalEntryType {
263    type Err = String;
264    fn from_str(s: &str) -> Result<Self, Self::Err> {
265        match s.to_lowercase().as_str() {
266            "standard" => Ok(Self::Standard),
267            "adjusting" => Ok(Self::Adjusting),
268            "closing" => Ok(Self::Closing),
269            "reversing" => Ok(Self::Reversing),
270            "opening" => Ok(Self::Opening),
271            _ => Err(format!("Unknown journal entry type: {s}")),
272        }
273    }
274}
275
276/// Journal entry source
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
278#[serde(rename_all = "snake_case")]
279#[non_exhaustive]
280pub enum JournalEntrySource {
281    /// Entry created manually by a user.
282    #[default]
283    Manual,
284    /// Auto-generated when a customer invoice is posted.
285    AutoInvoice,
286    /// Auto-generated when a customer payment is received.
287    AutoPayment,
288    /// Auto-generated when a supplier bill is approved.
289    AutoBill,
290    /// Auto-generated when a supplier bill payment is made.
291    AutoBillPayment,
292    /// Auto-generated from an inventory transaction.
293    AutoInventory,
294    /// Auto-generated when an AR balance is written off.
295    AutoWriteOff,
296    /// Generated automatically during period-close processing.
297    SystemClosing,
298    /// Imported from an external system or file.
299    Import,
300}
301
302impl fmt::Display for JournalEntrySource {
303    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304        match self {
305            Self::Manual => write!(f, "manual"),
306            Self::AutoInvoice => write!(f, "auto_invoice"),
307            Self::AutoPayment => write!(f, "auto_payment"),
308            Self::AutoBill => write!(f, "auto_bill"),
309            Self::AutoBillPayment => write!(f, "auto_bill_payment"),
310            Self::AutoInventory => write!(f, "auto_inventory"),
311            Self::AutoWriteOff => write!(f, "auto_write_off"),
312            Self::SystemClosing => write!(f, "system_closing"),
313            Self::Import => write!(f, "import"),
314        }
315    }
316}
317
318impl FromStr for JournalEntrySource {
319    type Err = String;
320    fn from_str(s: &str) -> Result<Self, Self::Err> {
321        match s.to_lowercase().as_str() {
322            "manual" => Ok(Self::Manual),
323            "auto_invoice" => Ok(Self::AutoInvoice),
324            "auto_payment" => Ok(Self::AutoPayment),
325            "auto_bill" => Ok(Self::AutoBill),
326            "auto_bill_payment" => Ok(Self::AutoBillPayment),
327            "auto_inventory" => Ok(Self::AutoInventory),
328            "auto_write_off" => Ok(Self::AutoWriteOff),
329            "system_closing" => Ok(Self::SystemClosing),
330            "import" => Ok(Self::Import),
331            _ => Err(format!("Unknown journal entry source: {s}")),
332        }
333    }
334}
335
336/// Journal entry status
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
338#[serde(rename_all = "snake_case")]
339#[non_exhaustive]
340pub enum JournalEntryStatus {
341    /// Entry is being prepared; not yet submitted for posting.
342    #[default]
343    Draft,
344    /// Entry is awaiting approval before posting.
345    Pending,
346    /// Entry has been posted and affects account balances.
347    Posted,
348    /// Entry has been cancelled; has no effect on balances.
349    Voided,
350    /// Entry has been offset by a reversing entry.
351    Reversed,
352}
353
354impl fmt::Display for JournalEntryStatus {
355    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356        match self {
357            Self::Draft => write!(f, "draft"),
358            Self::Pending => write!(f, "pending"),
359            Self::Posted => write!(f, "posted"),
360            Self::Voided => write!(f, "voided"),
361            Self::Reversed => write!(f, "reversed"),
362        }
363    }
364}
365
366impl FromStr for JournalEntryStatus {
367    type Err = String;
368    fn from_str(s: &str) -> Result<Self, Self::Err> {
369        match s.to_lowercase().as_str() {
370            "draft" => Ok(Self::Draft),
371            "pending" => Ok(Self::Pending),
372            "posted" => Ok(Self::Posted),
373            "voided" => Ok(Self::Voided),
374            "reversed" => Ok(Self::Reversed),
375            _ => Err(format!("Unknown journal entry status: {s}")),
376        }
377    }
378}
379
380// ============================================================================
381// Core GL Structs
382// ============================================================================
383
384/// A GL Account (Chart of Accounts entry)
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct GlAccount {
387    /// Unique identifier for this account.
388    pub id: Uuid,
389    /// Structured account code (e.g. `"1010"`).
390    pub account_number: String,
391    /// Human-readable account name.
392    pub name: String,
393    /// Optional description of the account's purpose.
394    pub description: Option<String>,
395    /// Top-level classification (Asset, Liability, Equity, Revenue, Expense).
396    pub account_type: AccountType,
397    /// Finer-grained classification within the account type.
398    pub account_sub_type: Option<AccountSubType>,
399    /// Parent account for hierarchy grouping; `None` if top-level.
400    pub parent_account_id: Option<Uuid>,
401    /// If `true`, this is a summary header; postings go to child accounts.
402    pub is_header: bool,
403    /// If `true`, journal entry lines may be posted directly to this account.
404    pub is_posting: bool,
405    /// Expected side (Debit/Credit) that increases this account.
406    pub normal_balance: BalanceSide,
407    /// Currency in which this account is maintained.
408    pub currency: CurrencyCode,
409    /// Lifecycle status of the account.
410    pub status: AccountStatus,
411    /// Running balance as of the last posting.
412    pub current_balance: Decimal,
413    /// Timestamp of account creation.
414    pub created_at: DateTime<Utc>,
415    /// Timestamp of the last update.
416    pub updated_at: DateTime<Utc>,
417}
418
419impl GlAccount {
420    /// Returns true if this account can accept postings
421    #[must_use]
422    pub fn can_post(&self) -> bool {
423        self.is_posting && self.status == AccountStatus::Active
424    }
425
426    /// Calculates the balance effect of a debit/credit
427    #[must_use]
428    pub fn balance_effect(&self, debit: Decimal, credit: Decimal) -> Decimal {
429        // Keep behavior consistent with account type, even if persisted normal_balance drifts.
430        match self.account_type.normal_balance() {
431            BalanceSide::Debit => debit - credit,
432            BalanceSide::Credit => credit - debit,
433        }
434    }
435}
436
437/// GL Period (accounting period)
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct GlPeriod {
440    /// Unique identifier for this period.
441    pub id: Uuid,
442    /// Display name, typically in `YYYY-MM` format.
443    pub period_name: String,
444    /// Fiscal year this period belongs to.
445    pub fiscal_year: i32,
446    /// Sequential number within the fiscal year (1–12 for monthly).
447    pub period_number: i32,
448    /// First date of the period (inclusive).
449    pub start_date: NaiveDate,
450    /// Last date of the period (inclusive).
451    pub end_date: NaiveDate,
452    /// Current lifecycle status of the period.
453    pub status: PeriodStatus,
454    /// Timestamp when the period was closed.
455    pub closed_at: Option<DateTime<Utc>>,
456    /// User who closed the period.
457    pub closed_by: Option<String>,
458    /// Timestamp when the period was permanently locked.
459    pub locked_at: Option<DateTime<Utc>>,
460    /// User who locked the period.
461    pub locked_by: Option<String>,
462    /// Timestamp of period creation.
463    pub created_at: DateTime<Utc>,
464    /// Timestamp of the last update.
465    pub updated_at: DateTime<Utc>,
466}
467
468impl GlPeriod {
469    /// Returns true if the period allows posting
470    #[must_use]
471    pub fn can_post(&self) -> bool {
472        self.status == PeriodStatus::Open
473    }
474
475    /// Returns true if a date falls within this period
476    #[must_use]
477    pub fn contains_date(&self, date: NaiveDate) -> bool {
478        date >= self.start_date && date <= self.end_date
479    }
480}
481
482/// Journal Entry header
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct JournalEntry {
485    /// Unique identifier for this journal entry.
486    pub id: Uuid,
487    /// Human-readable entry reference number (e.g. `"JE-20240101-ABCD1234"`).
488    pub entry_number: String,
489    /// Date the transaction occurred or is being recorded.
490    pub entry_date: NaiveDate,
491    /// Accounting period this entry belongs to.
492    pub period_id: Uuid,
493    /// Classification of the entry (standard, adjusting, closing, etc.).
494    pub entry_type: JournalEntryType,
495    /// System or process that created the entry.
496    pub source: JournalEntrySource,
497    /// Entity type of the originating document (e.g. `"invoice"`).
498    pub source_document_type: Option<String>,
499    /// Identifier of the originating document.
500    pub source_document_id: Option<Uuid>,
501    /// Narrative description of the transaction.
502    pub description: String,
503    /// Sum of all debit line amounts.
504    pub total_debits: Decimal,
505    /// Sum of all credit line amounts.
506    pub total_credits: Decimal,
507    /// `true` when `total_debits == total_credits`.
508    pub is_balanced: bool,
509    /// Current lifecycle status.
510    pub status: JournalEntryStatus,
511    /// Timestamp when the entry was posted to the ledger.
512    pub posted_at: Option<DateTime<Utc>>,
513    /// User who posted the entry.
514    pub posted_by: Option<String>,
515    /// Entry that this one reverses, if applicable.
516    pub reversed_entry_id: Option<Uuid>,
517    /// Entry created to reverse this one, if applicable.
518    pub reversing_entry_id: Option<Uuid>,
519    /// Individual debit/credit lines that make up this entry.
520    pub lines: Vec<JournalEntryLine>,
521    /// Timestamp of entry creation.
522    pub created_at: DateTime<Utc>,
523    /// Timestamp of the last update.
524    pub updated_at: DateTime<Utc>,
525}
526
527impl JournalEntry {
528    /// Returns true if debits equal credits
529    #[must_use]
530    pub fn is_balanced(&self) -> bool {
531        self.total_debits == self.total_credits
532    }
533
534    /// Recalculates totals from lines
535    pub fn recalculate_totals(&mut self) {
536        self.total_debits = self.lines.iter().map(|l| l.debit_amount).sum();
537        self.total_credits = self.lines.iter().map(|l| l.credit_amount).sum();
538        self.is_balanced = self.total_debits == self.total_credits;
539    }
540
541    /// Returns true if entry can be posted
542    pub fn can_post(&self) -> bool {
543        if self.status != JournalEntryStatus::Draft || self.lines.is_empty() {
544            return false;
545        }
546
547        if !self.lines.iter().all(JournalEntryLine::is_valid) {
548            return false;
549        }
550
551        let calculated_debits: Decimal = self.lines.iter().map(|l| l.debit_amount).sum();
552        let calculated_credits: Decimal = self.lines.iter().map(|l| l.credit_amount).sum();
553        self.total_debits == calculated_debits
554            && self.total_credits == calculated_credits
555            && calculated_debits == calculated_credits
556    }
557
558    /// Returns true if entry can be voided
559    #[must_use]
560    pub fn can_void(&self) -> bool {
561        self.status == JournalEntryStatus::Posted
562    }
563}
564
565/// Journal Entry line (detail)
566#[derive(Debug, Clone, Serialize, Deserialize)]
567pub struct JournalEntryLine {
568    /// Unique identifier for this line.
569    pub id: Uuid,
570    /// Parent journal entry.
571    pub journal_entry_id: Uuid,
572    /// Sequence number within the entry (1-based).
573    pub line_number: i32,
574    /// GL account being debited or credited.
575    pub account_id: Uuid,
576    /// Denormalized account number for reporting convenience.
577    pub account_number: Option<String>,
578    /// Denormalized account name for reporting convenience.
579    pub account_name: Option<String>,
580    /// Optional line-level narrative.
581    pub description: Option<String>,
582    /// Debit amount; exactly one of `debit_amount` or `credit_amount` must be non-zero.
583    pub debit_amount: Decimal,
584    /// Credit amount; exactly one of `debit_amount` or `credit_amount` must be non-zero.
585    pub credit_amount: Decimal,
586    /// Currency of the amounts on this line.
587    pub currency: CurrencyCode,
588    /// Entity type of a related sub-ledger record (e.g. `"invoice_line"`).
589    pub reference_type: Option<String>,
590    /// Identifier of the related sub-ledger record.
591    pub reference_id: Option<Uuid>,
592    /// Timestamp of line creation.
593    pub created_at: DateTime<Utc>,
594}
595
596impl JournalEntryLine {
597    /// Returns true if line has only debit or only credit
598    #[must_use]
599    pub fn is_valid(&self) -> bool {
600        (self.debit_amount > Decimal::ZERO && self.credit_amount == Decimal::ZERO)
601            || (self.debit_amount == Decimal::ZERO && self.credit_amount > Decimal::ZERO)
602    }
603
604    /// Returns the net amount (positive for debit, negative for credit)
605    #[must_use]
606    pub fn net_amount(&self) -> Decimal {
607        self.debit_amount - self.credit_amount
608    }
609}
610
611/// Auto-posting configuration
612#[derive(Debug, Clone, Serialize, Deserialize)]
613pub struct AutoPostingConfig {
614    pub id: Uuid,
615    pub config_name: String,
616    pub cash_account_id: Uuid,
617    pub accounts_receivable_account_id: Uuid,
618    pub inventory_account_id: Uuid,
619    pub accounts_payable_account_id: Uuid,
620    pub unearned_revenue_account_id: Option<Uuid>,
621    pub sales_revenue_account_id: Uuid,
622    pub shipping_revenue_account_id: Option<Uuid>,
623    pub cogs_account_id: Uuid,
624    pub bad_debt_expense_account_id: Option<Uuid>,
625    /// Account receiving unrealized FX gains/losses posted by period-end revaluation.
626    #[serde(default)]
627    pub fx_gain_loss_account_id: Option<Uuid>,
628    /// Auto-post a journal entry when fixed-asset depreciation is posted.
629    #[serde(default)]
630    pub auto_post_depreciation: bool,
631    /// Auto-post a journal entry when deferred revenue is recognized.
632    #[serde(default)]
633    pub auto_post_revenue_recognition: bool,
634    pub is_active: bool,
635    pub created_at: DateTime<Utc>,
636    pub updated_at: DateTime<Utc>,
637}
638
639/// Account balance for a period
640#[derive(Debug, Clone, Serialize, Deserialize)]
641pub struct AccountBalance {
642    pub id: Uuid,
643    pub account_id: Uuid,
644    pub period_id: Uuid,
645    pub opening_balance: Decimal,
646    pub total_debits: Decimal,
647    pub total_credits: Decimal,
648    pub closing_balance: Decimal,
649    pub created_at: DateTime<Utc>,
650    pub updated_at: DateTime<Utc>,
651}
652
653// ============================================================================
654// Financial Reports
655// ============================================================================
656
657/// Trial Balance line
658#[derive(Debug, Clone, Serialize, Deserialize)]
659pub struct TrialBalanceLine {
660    pub account_id: Uuid,
661    pub account_number: String,
662    pub account_name: String,
663    pub account_type: AccountType,
664    pub debit_balance: Decimal,
665    pub credit_balance: Decimal,
666}
667
668/// Trial Balance report
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct TrialBalance {
671    pub as_of_date: NaiveDate,
672    pub period_id: Option<Uuid>,
673    pub total_debits: Decimal,
674    pub total_credits: Decimal,
675    pub is_balanced: bool,
676    pub lines: Vec<TrialBalanceLine>,
677}
678
679impl TrialBalance {
680    /// Returns true if debits equal credits
681    #[must_use]
682    pub fn is_balanced(&self) -> bool {
683        self.total_debits == self.total_credits
684    }
685}
686
687/// Balance Sheet line item
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct BalanceSheetLine {
690    pub account_id: Uuid,
691    pub account_number: String,
692    pub account_name: String,
693    pub account_sub_type: Option<AccountSubType>,
694    pub balance: Decimal,
695    pub indent_level: i32,
696    pub is_total: bool,
697}
698
699/// Balance Sheet report
700#[derive(Debug, Clone, Serialize, Deserialize)]
701pub struct BalanceSheet {
702    pub as_of_date: NaiveDate,
703    pub total_assets: Decimal,
704    pub total_liabilities: Decimal,
705    pub total_equity: Decimal,
706    pub assets: Vec<BalanceSheetLine>,
707    pub liabilities: Vec<BalanceSheetLine>,
708    pub equity: Vec<BalanceSheetLine>,
709}
710
711impl BalanceSheet {
712    /// Returns true if assets equal liabilities plus equity
713    #[must_use]
714    pub fn is_balanced(&self) -> bool {
715        self.total_assets == self.total_liabilities + self.total_equity
716    }
717}
718
719/// Income Statement line item
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct IncomeStatementLine {
722    pub account_id: Uuid,
723    pub account_number: String,
724    pub account_name: String,
725    pub account_sub_type: Option<AccountSubType>,
726    pub amount: Decimal,
727    pub indent_level: i32,
728    pub is_total: bool,
729}
730
731/// Income Statement report
732#[derive(Debug, Clone, Serialize, Deserialize)]
733pub struct IncomeStatement {
734    pub period_start: NaiveDate,
735    pub period_end: NaiveDate,
736    pub total_revenue: Decimal,
737    pub total_expenses: Decimal,
738    pub net_income: Decimal,
739    pub revenue_lines: Vec<IncomeStatementLine>,
740    pub expense_lines: Vec<IncomeStatementLine>,
741}
742
743// ============================================================================
744// Input Types
745// ============================================================================
746
747/// Input for creating a general ledger account
748#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct CreateGlAccount {
750    pub account_number: String,
751    pub name: String,
752    pub description: Option<String>,
753    pub account_type: AccountType,
754    pub account_sub_type: Option<AccountSubType>,
755    pub parent_account_id: Option<Uuid>,
756    pub is_header: Option<bool>,
757    pub is_posting: Option<bool>,
758    pub currency: Option<CurrencyCode>,
759}
760
761/// Input for updating a general ledger account
762#[derive(Debug, Clone, Serialize, Deserialize, Default)]
763pub struct UpdateGlAccount {
764    pub name: Option<String>,
765    pub description: Option<String>,
766    pub parent_account_id: Option<Uuid>,
767    pub status: Option<AccountStatus>,
768}
769
770/// Input for creating a fiscal period
771#[derive(Debug, Clone, Serialize, Deserialize)]
772pub struct CreateGlPeriod {
773    pub period_name: String,
774    pub fiscal_year: i32,
775    pub period_number: i32,
776    pub start_date: NaiveDate,
777    pub end_date: NaiveDate,
778}
779
780/// Input for creating a journal entry with lines
781#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct CreateJournalEntry {
783    pub entry_date: NaiveDate,
784    pub entry_type: Option<JournalEntryType>,
785    pub description: String,
786    pub lines: Vec<CreateJournalEntryLine>,
787    pub source_document_type: Option<String>,
788    pub source_document_id: Option<Uuid>,
789    pub auto_post: Option<bool>,
790}
791
792/// Input for a journal entry line
793#[derive(Debug, Clone, Serialize, Deserialize)]
794pub struct CreateJournalEntryLine {
795    pub account_id: Uuid,
796    pub description: Option<String>,
797    pub debit_amount: Decimal,
798    pub credit_amount: Decimal,
799    pub reference_type: Option<String>,
800    pub reference_id: Option<Uuid>,
801}
802
803impl CreateJournalEntryLine {
804    /// Create a debit line for an account
805    #[must_use]
806    pub const fn debit(account_id: Uuid, amount: Decimal, description: Option<String>) -> Self {
807        Self {
808            account_id,
809            description,
810            debit_amount: amount,
811            credit_amount: Decimal::ZERO,
812            reference_type: None,
813            reference_id: None,
814        }
815    }
816
817    /// Create a credit line for an account
818    #[must_use]
819    pub const fn credit(account_id: Uuid, amount: Decimal, description: Option<String>) -> Self {
820        Self {
821            account_id,
822            description,
823            debit_amount: Decimal::ZERO,
824            credit_amount: amount,
825            reference_type: None,
826            reference_id: None,
827        }
828    }
829}
830
831/// Configuration for automatic postings between accounts
832#[derive(Debug, Clone, Serialize, Deserialize)]
833pub struct CreateAutoPostingConfig {
834    pub config_name: String,
835    pub cash_account_id: Uuid,
836    pub accounts_receivable_account_id: Uuid,
837    pub inventory_account_id: Uuid,
838    pub accounts_payable_account_id: Uuid,
839    pub unearned_revenue_account_id: Option<Uuid>,
840    pub sales_revenue_account_id: Uuid,
841    pub shipping_revenue_account_id: Option<Uuid>,
842    pub cogs_account_id: Uuid,
843    pub bad_debt_expense_account_id: Option<Uuid>,
844    /// Account receiving unrealized FX gains/losses posted by period-end revaluation.
845    #[serde(default)]
846    pub fx_gain_loss_account_id: Option<Uuid>,
847    /// Auto-post a journal entry when fixed-asset depreciation is posted (default off).
848    #[serde(default)]
849    pub auto_post_depreciation: bool,
850    /// Auto-post a journal entry when deferred revenue is recognized (default off).
851    #[serde(default)]
852    pub auto_post_revenue_recognition: bool,
853}
854
855// ============================================================================
856// Filter Types
857// ============================================================================
858
859/// Filter for listing general ledger accounts
860#[derive(Debug, Clone, Serialize, Deserialize, Default)]
861pub struct GlAccountFilter {
862    pub account_type: Option<AccountType>,
863    pub account_sub_type: Option<AccountSubType>,
864    pub parent_account_id: Option<Uuid>,
865    pub status: Option<AccountStatus>,
866    pub is_posting: Option<bool>,
867    pub is_header: Option<bool>,
868    pub search: Option<String>,
869    pub limit: Option<u32>,
870    pub offset: Option<u32>,
871}
872
873/// Filter for listing fiscal periods
874#[derive(Debug, Clone, Serialize, Deserialize, Default)]
875pub struct GlPeriodFilter {
876    pub fiscal_year: Option<i32>,
877    pub status: Option<PeriodStatus>,
878    pub limit: Option<u32>,
879    pub offset: Option<u32>,
880}
881
882/// Filter for listing journal entries
883#[derive(Debug, Clone, Serialize, Deserialize, Default)]
884pub struct JournalEntryFilter {
885    pub period_id: Option<Uuid>,
886    pub entry_type: Option<JournalEntryType>,
887    pub source: Option<JournalEntrySource>,
888    pub status: Option<JournalEntryStatus>,
889    pub account_id: Option<Uuid>,
890    pub from_date: Option<NaiveDate>,
891    pub to_date: Option<NaiveDate>,
892    pub source_document_type: Option<String>,
893    pub source_document_id: Option<Uuid>,
894    pub search: Option<String>,
895    pub limit: Option<u32>,
896    pub offset: Option<u32>,
897}
898
899// ============================================================================
900// FX Revaluation
901// ============================================================================
902
903/// `reference_type` stamped on journal entry lines created by FX revaluation.
904///
905/// Lines carrying this marker are base-currency adjustments, so they are
906/// excluded when deriving an account's outstanding foreign-currency balance
907/// for subsequent revaluations.
908pub const FX_REVALUATION_REFERENCE: &str = "fx_revaluation";
909
910/// Per-account result of a period-end FX revaluation.
911#[derive(Debug, Clone, Serialize, Deserialize)]
912pub struct RevaluationLine {
913    /// Account being revalued.
914    pub account_id: Uuid,
915    /// Denormalized account number.
916    pub account_number: String,
917    /// Denormalized account name.
918    pub account_name: String,
919    /// Currency the account is maintained in (differs from base currency).
920    pub currency: CurrencyCode,
921    /// Side (Debit/Credit) that increases this account.
922    pub normal_balance: BalanceSide,
923    /// Outstanding balance in the account's own currency (normal-balance
924    /// terms), derived from posted lines excluding prior FX adjustments.
925    pub foreign_balance: Decimal,
926    /// Value currently carried on the books (base-currency terms).
927    pub carrying_value: Decimal,
928    /// Exchange rate used: 1 unit of account currency = `rate` base units.
929    pub rate: Decimal,
930    /// `foreign_balance * rate`, rounded to base-currency precision.
931    pub revalued_value: Decimal,
932    /// `revalued_value - carrying_value` in normal-balance terms.
933    pub adjustment: Decimal,
934    /// Unrealized FX gain (positive) or loss (negative) in base currency.
935    pub unrealized_gain_loss: Decimal,
936}
937
938/// Result of a period-end FX revaluation run.
939#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct RevaluationResult {
941    /// Date the revaluation is effective (journal entry date).
942    pub as_of_date: NaiveDate,
943    /// Base (functional) currency balances were revalued into.
944    pub base_currency: CurrencyCode,
945    /// Sum of `unrealized_gain_loss` across all lines.
946    pub total_unrealized_gain_loss: Decimal,
947    /// Per-account revaluation detail (includes zero-adjustment accounts).
948    pub lines: Vec<RevaluationLine>,
949    /// Balanced, posted adjusting entry for the net delta; `None` when every
950    /// evaluated account required no adjustment.
951    pub journal_entry: Option<JournalEntry>,
952}
953
954/// Compute the unrealized FX gain/loss for one foreign-currency account.
955///
956/// `foreign_balance` is the account's outstanding balance in its own currency
957/// (normal-balance terms, excluding prior revaluation adjustments); `rate`
958/// converts one unit of the account currency into the base currency;
959/// `base_decimal_places` is the base currency's precision.
960#[must_use]
961pub fn compute_revaluation_line(
962    account: &GlAccount,
963    foreign_balance: Decimal,
964    rate: Decimal,
965    base_decimal_places: u32,
966) -> RevaluationLine {
967    let normal_balance = account.account_type.normal_balance();
968    let revalued_value = (foreign_balance * rate).round_dp(base_decimal_places);
969    let adjustment = revalued_value - account.current_balance;
970    // Growing a debit-normal account (asset) is a gain; growing a
971    // credit-normal account (liability) is a loss.
972    let unrealized_gain_loss = match normal_balance {
973        BalanceSide::Credit => -adjustment,
974        _ => adjustment,
975    };
976    RevaluationLine {
977        account_id: account.id,
978        account_number: account.account_number.clone(),
979        account_name: account.name.clone(),
980        currency: account.currency,
981        normal_balance,
982        foreign_balance,
983        carrying_value: account.current_balance,
984        rate,
985        revalued_value,
986        adjustment,
987        unrealized_gain_loss,
988    }
989}
990
991/// Build balanced journal entry lines for a set of revaluation adjustments.
992///
993/// Each non-zero adjustment posts on the side that moves the account toward
994/// its revalued carrying amount; the net offset posts to `fx_account_id`
995/// (credit for a net gain, debit for a net loss). Returns an empty vector
996/// when no account requires adjustment.
997#[must_use]
998pub fn build_revaluation_journal_lines(
999    lines: &[RevaluationLine],
1000    fx_account_id: Uuid,
1001) -> Vec<CreateJournalEntryLine> {
1002    let mut entry_lines = Vec::new();
1003    // Positive => the FX account must be credited (net gain).
1004    let mut fx_net = Decimal::ZERO;
1005
1006    for line in lines {
1007        if line.adjustment.is_zero() {
1008            continue;
1009        }
1010        let amount = line.adjustment.abs();
1011        let increase = line.adjustment > Decimal::ZERO;
1012        let debit_side = match line.normal_balance {
1013            BalanceSide::Credit => !increase,
1014            _ => increase,
1015        };
1016        let (debit_amount, credit_amount) = if debit_side {
1017            fx_net += amount;
1018            (amount, Decimal::ZERO)
1019        } else {
1020            fx_net -= amount;
1021            (Decimal::ZERO, amount)
1022        };
1023        entry_lines.push(CreateJournalEntryLine {
1024            account_id: line.account_id,
1025            description: Some(format!(
1026                "FX revaluation of {} ({})",
1027                line.account_number, line.currency
1028            )),
1029            debit_amount,
1030            credit_amount,
1031            reference_type: Some(FX_REVALUATION_REFERENCE.to_string()),
1032            reference_id: Some(line.account_id),
1033        });
1034    }
1035
1036    if !fx_net.is_zero() {
1037        let amount = fx_net.abs();
1038        let (debit_amount, credit_amount) =
1039            if fx_net > Decimal::ZERO { (Decimal::ZERO, amount) } else { (amount, Decimal::ZERO) };
1040        entry_lines.push(CreateJournalEntryLine {
1041            account_id: fx_account_id,
1042            description: Some("Unrealized FX gain/loss".to_string()),
1043            debit_amount,
1044            credit_amount,
1045            reference_type: Some(FX_REVALUATION_REFERENCE.to_string()),
1046            reference_id: None,
1047        });
1048    }
1049
1050    entry_lines
1051}
1052
1053// ============================================================================
1054// Helper Functions
1055// ============================================================================
1056
1057/// Generate a journal entry number using a timestamp
1058#[must_use]
1059pub fn generate_journal_entry_number() -> String {
1060    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S%3f").to_string();
1061    let suffix = Uuid::new_v4().simple().to_string();
1062    format!("JE-{}-{}", timestamp, &suffix[..8])
1063}
1064
1065/// Generate a period name in YYYY-MM format
1066#[must_use]
1067pub fn generate_period_name(year: i32, month: i32) -> String {
1068    format!("{year}-{month:02}")
1069}
1070
1071/// Create a default Chart of Accounts
1072#[must_use]
1073pub fn create_default_chart_of_accounts() -> Vec<CreateGlAccount> {
1074    vec![
1075        // Assets (1xxx)
1076        CreateGlAccount {
1077            account_number: "1000".into(),
1078            name: "Assets".into(),
1079            description: Some("All asset accounts".into()),
1080            account_type: AccountType::Asset,
1081            account_sub_type: None,
1082            parent_account_id: None,
1083            is_header: Some(true),
1084            is_posting: Some(false),
1085            currency: None,
1086        },
1087        CreateGlAccount {
1088            account_number: "1010".into(),
1089            name: "Cash".into(),
1090            description: Some("Cash and cash equivalents".into()),
1091            account_type: AccountType::Asset,
1092            account_sub_type: Some(AccountSubType::Cash),
1093            parent_account_id: None,
1094            is_header: Some(false),
1095            is_posting: Some(true),
1096            currency: None,
1097        },
1098        CreateGlAccount {
1099            account_number: "1100".into(),
1100            name: "Accounts Receivable".into(),
1101            description: Some("Customer receivables".into()),
1102            account_type: AccountType::Asset,
1103            account_sub_type: Some(AccountSubType::AccountsReceivable),
1104            parent_account_id: None,
1105            is_header: Some(false),
1106            is_posting: Some(true),
1107            currency: None,
1108        },
1109        CreateGlAccount {
1110            account_number: "1200".into(),
1111            name: "Inventory".into(),
1112            description: Some("Merchandise inventory".into()),
1113            account_type: AccountType::Asset,
1114            account_sub_type: Some(AccountSubType::Inventory),
1115            parent_account_id: None,
1116            is_header: Some(false),
1117            is_posting: Some(true),
1118            currency: None,
1119        },
1120        // Liabilities (2xxx)
1121        CreateGlAccount {
1122            account_number: "2000".into(),
1123            name: "Liabilities".into(),
1124            description: Some("All liability accounts".into()),
1125            account_type: AccountType::Liability,
1126            account_sub_type: None,
1127            parent_account_id: None,
1128            is_header: Some(true),
1129            is_posting: Some(false),
1130            currency: None,
1131        },
1132        CreateGlAccount {
1133            account_number: "2010".into(),
1134            name: "Accounts Payable".into(),
1135            description: Some("Supplier payables".into()),
1136            account_type: AccountType::Liability,
1137            account_sub_type: Some(AccountSubType::AccountsPayable),
1138            parent_account_id: None,
1139            is_header: Some(false),
1140            is_posting: Some(true),
1141            currency: None,
1142        },
1143        // Equity (3xxx)
1144        CreateGlAccount {
1145            account_number: "3000".into(),
1146            name: "Equity".into(),
1147            description: Some("Owner's equity accounts".into()),
1148            account_type: AccountType::Equity,
1149            account_sub_type: None,
1150            parent_account_id: None,
1151            is_header: Some(true),
1152            is_posting: Some(false),
1153            currency: None,
1154        },
1155        CreateGlAccount {
1156            account_number: "3100".into(),
1157            name: "Retained Earnings".into(),
1158            description: Some("Accumulated profits".into()),
1159            account_type: AccountType::Equity,
1160            account_sub_type: Some(AccountSubType::RetainedEarnings),
1161            parent_account_id: None,
1162            is_header: Some(false),
1163            is_posting: Some(true),
1164            currency: None,
1165        },
1166        // Revenue (4xxx)
1167        CreateGlAccount {
1168            account_number: "4000".into(),
1169            name: "Revenue".into(),
1170            description: Some("All revenue accounts".into()),
1171            account_type: AccountType::Revenue,
1172            account_sub_type: None,
1173            parent_account_id: None,
1174            is_header: Some(true),
1175            is_posting: Some(false),
1176            currency: None,
1177        },
1178        CreateGlAccount {
1179            account_number: "4010".into(),
1180            name: "Sales Revenue".into(),
1181            description: Some("Product sales".into()),
1182            account_type: AccountType::Revenue,
1183            account_sub_type: Some(AccountSubType::SalesRevenue),
1184            parent_account_id: None,
1185            is_header: Some(false),
1186            is_posting: Some(true),
1187            currency: None,
1188        },
1189        // Expenses (5xxx)
1190        CreateGlAccount {
1191            account_number: "5000".into(),
1192            name: "Expenses".into(),
1193            description: Some("All expense accounts".into()),
1194            account_type: AccountType::Expense,
1195            account_sub_type: None,
1196            parent_account_id: None,
1197            is_header: Some(true),
1198            is_posting: Some(false),
1199            currency: None,
1200        },
1201        CreateGlAccount {
1202            account_number: "5010".into(),
1203            name: "Cost of Goods Sold".into(),
1204            description: Some("Direct cost of products sold".into()),
1205            account_type: AccountType::Expense,
1206            account_sub_type: Some(AccountSubType::CostOfGoodsSold),
1207            parent_account_id: None,
1208            is_header: Some(false),
1209            is_posting: Some(true),
1210            currency: None,
1211        },
1212        CreateGlAccount {
1213            account_number: "5900".into(),
1214            name: "Bad Debt Expense".into(),
1215            description: Some("Uncollectible accounts written off".into()),
1216            account_type: AccountType::Expense,
1217            account_sub_type: Some(AccountSubType::OtherExpense),
1218            parent_account_id: None,
1219            is_header: Some(false),
1220            is_posting: Some(true),
1221            currency: None,
1222        },
1223    ]
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229    use rust_decimal_macros::dec;
1230
1231    fn foreign_account(account_type: AccountType, carrying: Decimal) -> GlAccount {
1232        let now = Utc::now();
1233        GlAccount {
1234            id: Uuid::new_v4(),
1235            account_number: "1015".into(),
1236            name: "EUR Cash".into(),
1237            description: None,
1238            account_type,
1239            account_sub_type: None,
1240            parent_account_id: None,
1241            is_header: false,
1242            is_posting: true,
1243            normal_balance: account_type.normal_balance(),
1244            currency: CurrencyCode::EUR,
1245            status: AccountStatus::Active,
1246            current_balance: carrying,
1247            created_at: now,
1248            updated_at: now,
1249        }
1250    }
1251
1252    #[test]
1253    fn revaluation_gain_on_debit_normal_account() {
1254        // Booked 1000 EUR at 1.00; rate is now 1.10 => 100 unrealized gain.
1255        let account = foreign_account(AccountType::Asset, dec!(1000));
1256        let line = compute_revaluation_line(&account, dec!(1000), dec!(1.10), 2);
1257        assert_eq!(line.revalued_value, dec!(1100.00));
1258        assert_eq!(line.adjustment, dec!(100.00));
1259        assert_eq!(line.unrealized_gain_loss, dec!(100.00));
1260    }
1261
1262    #[test]
1263    fn revaluation_loss_on_debit_normal_account() {
1264        let account = foreign_account(AccountType::Asset, dec!(1000));
1265        let line = compute_revaluation_line(&account, dec!(1000), dec!(0.85), 2);
1266        assert_eq!(line.adjustment, dec!(-150.00));
1267        assert_eq!(line.unrealized_gain_loss, dec!(-150.00));
1268    }
1269
1270    #[test]
1271    fn revaluation_on_credit_normal_account_flips_sign() {
1272        // A payable growing in base terms is a loss.
1273        let account = foreign_account(AccountType::Liability, dec!(500));
1274        let line = compute_revaluation_line(&account, dec!(500), dec!(1.20), 2);
1275        assert_eq!(line.adjustment, dec!(100.00));
1276        assert_eq!(line.unrealized_gain_loss, dec!(-100.00));
1277    }
1278
1279    #[test]
1280    fn revaluation_noop_when_rate_unchanged() {
1281        let account = foreign_account(AccountType::Asset, dec!(1000));
1282        let line = compute_revaluation_line(&account, dec!(1000), dec!(1), 2);
1283        assert!(line.adjustment.is_zero());
1284        assert!(line.unrealized_gain_loss.is_zero());
1285        assert!(build_revaluation_journal_lines(&[line], Uuid::new_v4()).is_empty());
1286    }
1287
1288    #[test]
1289    fn revaluation_rounds_to_base_precision() {
1290        let account = foreign_account(AccountType::Asset, dec!(0));
1291        let line = compute_revaluation_line(&account, dec!(100.333), dec!(1.005), 2);
1292        assert_eq!(line.revalued_value, dec!(100.83));
1293    }
1294
1295    #[test]
1296    fn revaluation_journal_lines_are_balanced() {
1297        let asset = foreign_account(AccountType::Asset, dec!(1000));
1298        let liability = foreign_account(AccountType::Liability, dec!(500));
1299        let fx_account = Uuid::new_v4();
1300
1301        let lines = vec![
1302            compute_revaluation_line(&asset, dec!(1000), dec!(1.10), 2), // +100 gain
1303            compute_revaluation_line(&liability, dec!(500), dec!(1.20), 2), // -100 loss
1304        ];
1305        let je_lines = build_revaluation_journal_lines(&lines, fx_account);
1306
1307        // Asset debit 100, liability credit 100 — nets to zero, so no FX line.
1308        assert_eq!(je_lines.len(), 2);
1309        let debits: Decimal = je_lines.iter().map(|l| l.debit_amount).sum();
1310        let credits: Decimal = je_lines.iter().map(|l| l.credit_amount).sum();
1311        assert_eq!(debits, credits);
1312        assert!(je_lines.iter().all(|l| l.reference_type.as_deref() == Some("fx_revaluation")));
1313    }
1314
1315    #[test]
1316    fn revaluation_journal_lines_offset_net_gain_to_fx_account() {
1317        let asset = foreign_account(AccountType::Asset, dec!(1000));
1318        let fx_account = Uuid::new_v4();
1319        let lines = vec![compute_revaluation_line(&asset, dec!(1000), dec!(1.10), 2)];
1320        let je_lines = build_revaluation_journal_lines(&lines, fx_account);
1321
1322        assert_eq!(je_lines.len(), 2);
1323        assert_eq!(je_lines[0].debit_amount, dec!(100.00));
1324        assert_eq!(je_lines[1].account_id, fx_account);
1325        assert_eq!(je_lines[1].credit_amount, dec!(100.00));
1326    }
1327
1328    #[test]
1329    fn revaluation_journal_lines_offset_net_loss_to_fx_account() {
1330        let liability = foreign_account(AccountType::Liability, dec!(500));
1331        let fx_account = Uuid::new_v4();
1332        let lines = vec![compute_revaluation_line(&liability, dec!(500), dec!(1.20), 2)];
1333        let je_lines = build_revaluation_journal_lines(&lines, fx_account);
1334
1335        assert_eq!(je_lines.len(), 2);
1336        assert_eq!(je_lines[0].credit_amount, dec!(100.00));
1337        assert_eq!(je_lines[1].account_id, fx_account);
1338        assert_eq!(je_lines[1].debit_amount, dec!(100.00));
1339    }
1340}