1use 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#[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 Asset,
31 Liability,
33 Equity,
35 Revenue,
37 Expense,
39}
40
41impl AccountType {
42 #[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 #[must_use]
53 pub const fn is_balance_sheet(&self) -> bool {
54 matches!(self, Self::Asset | Self::Liability | Self::Equity)
55 }
56
57 #[must_use]
59 pub const fn is_income_statement(&self) -> bool {
60 matches!(self, Self::Revenue | Self::Expense)
61 }
62}
63
64#[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 #[default]
74 Debit,
75 Credit,
77}
78
79#[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 Cash,
88 AccountsReceivable,
90 Inventory,
92 PrepaidExpense,
94 FixedAsset,
96 AccumulatedDepreciation,
98 OtherCurrentAsset,
100 OtherNonCurrentAsset,
102 AccountsPayable,
105 AccruedLiabilities,
107 UnearnedRevenue,
109 ShortTermDebt,
111 LongTermDebt,
113 OtherCurrentLiability,
115 OtherNonCurrentLiability,
117 CommonStock,
120 RetainedEarnings,
122 OtherEquity,
124 SalesRevenue,
127 ServiceRevenue,
129 OtherRevenue,
131 CostOfGoodsSold,
134 OperatingExpense,
136 Payroll,
138 RentExpense,
140 UtilitiesExpense,
142 DepreciationExpense,
144 InterestExpense,
146 TaxExpense,
148 OtherExpense,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
154#[serde(rename_all = "snake_case")]
155#[non_exhaustive]
156pub enum AccountStatus {
157 #[default]
159 Active,
160 Inactive,
162 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
194#[serde(rename_all = "snake_case")]
195#[non_exhaustive]
196pub enum PeriodStatus {
197 #[default]
199 Future,
200 Open,
202 Closed,
204 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
234#[serde(rename_all = "snake_case")]
235#[non_exhaustive]
236pub enum JournalEntryType {
237 #[default]
239 Standard,
240 Adjusting,
242 Closing,
244 Reversing,
246 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
278#[serde(rename_all = "snake_case")]
279#[non_exhaustive]
280pub enum JournalEntrySource {
281 #[default]
283 Manual,
284 AutoInvoice,
286 AutoPayment,
288 AutoBill,
290 AutoBillPayment,
292 AutoInventory,
294 AutoWriteOff,
296 SystemClosing,
298 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
338#[serde(rename_all = "snake_case")]
339#[non_exhaustive]
340pub enum JournalEntryStatus {
341 #[default]
343 Draft,
344 Pending,
346 Posted,
348 Voided,
350 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#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct GlAccount {
387 pub id: Uuid,
389 pub account_number: String,
391 pub name: String,
393 pub description: Option<String>,
395 pub account_type: AccountType,
397 pub account_sub_type: Option<AccountSubType>,
399 pub parent_account_id: Option<Uuid>,
401 pub is_header: bool,
403 pub is_posting: bool,
405 pub normal_balance: BalanceSide,
407 pub currency: CurrencyCode,
409 pub status: AccountStatus,
411 pub current_balance: Decimal,
413 pub created_at: DateTime<Utc>,
415 pub updated_at: DateTime<Utc>,
417}
418
419impl GlAccount {
420 #[must_use]
422 pub fn can_post(&self) -> bool {
423 self.is_posting && self.status == AccountStatus::Active
424 }
425
426 #[must_use]
428 pub fn balance_effect(&self, debit: Decimal, credit: Decimal) -> Decimal {
429 match self.account_type.normal_balance() {
431 BalanceSide::Debit => debit - credit,
432 BalanceSide::Credit => credit - debit,
433 }
434 }
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct GlPeriod {
440 pub id: Uuid,
442 pub period_name: String,
444 pub fiscal_year: i32,
446 pub period_number: i32,
448 pub start_date: NaiveDate,
450 pub end_date: NaiveDate,
452 pub status: PeriodStatus,
454 pub closed_at: Option<DateTime<Utc>>,
456 pub closed_by: Option<String>,
458 pub locked_at: Option<DateTime<Utc>>,
460 pub locked_by: Option<String>,
462 pub created_at: DateTime<Utc>,
464 pub updated_at: DateTime<Utc>,
466}
467
468impl GlPeriod {
469 #[must_use]
471 pub fn can_post(&self) -> bool {
472 self.status == PeriodStatus::Open
473 }
474
475 #[must_use]
477 pub fn contains_date(&self, date: NaiveDate) -> bool {
478 date >= self.start_date && date <= self.end_date
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct JournalEntry {
485 pub id: Uuid,
487 pub entry_number: String,
489 pub entry_date: NaiveDate,
491 pub period_id: Uuid,
493 pub entry_type: JournalEntryType,
495 pub source: JournalEntrySource,
497 pub source_document_type: Option<String>,
499 pub source_document_id: Option<Uuid>,
501 pub description: String,
503 pub total_debits: Decimal,
505 pub total_credits: Decimal,
507 pub is_balanced: bool,
509 pub status: JournalEntryStatus,
511 pub posted_at: Option<DateTime<Utc>>,
513 pub posted_by: Option<String>,
515 pub reversed_entry_id: Option<Uuid>,
517 pub reversing_entry_id: Option<Uuid>,
519 pub lines: Vec<JournalEntryLine>,
521 pub created_at: DateTime<Utc>,
523 pub updated_at: DateTime<Utc>,
525}
526
527impl JournalEntry {
528 #[must_use]
530 pub fn is_balanced(&self) -> bool {
531 self.total_debits == self.total_credits
532 }
533
534 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 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 #[must_use]
560 pub fn can_void(&self) -> bool {
561 self.status == JournalEntryStatus::Posted
562 }
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize)]
567pub struct JournalEntryLine {
568 pub id: Uuid,
570 pub journal_entry_id: Uuid,
572 pub line_number: i32,
574 pub account_id: Uuid,
576 pub account_number: Option<String>,
578 pub account_name: Option<String>,
580 pub description: Option<String>,
582 pub debit_amount: Decimal,
584 pub credit_amount: Decimal,
586 pub currency: CurrencyCode,
588 pub reference_type: Option<String>,
590 pub reference_id: Option<Uuid>,
592 pub created_at: DateTime<Utc>,
594}
595
596impl JournalEntryLine {
597 #[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 #[must_use]
606 pub fn net_amount(&self) -> Decimal {
607 self.debit_amount - self.credit_amount
608 }
609}
610
611#[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 #[serde(default)]
627 pub fx_gain_loss_account_id: Option<Uuid>,
628 #[serde(default)]
630 pub auto_post_depreciation: bool,
631 #[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#[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#[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#[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 #[must_use]
682 pub fn is_balanced(&self) -> bool {
683 self.total_debits == self.total_credits
684 }
685}
686
687#[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#[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 #[must_use]
714 pub fn is_balanced(&self) -> bool {
715 self.total_assets == self.total_liabilities + self.total_equity
716 }
717}
718
719#[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#[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#[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#[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#[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#[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#[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 #[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 #[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#[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 #[serde(default)]
846 pub fx_gain_loss_account_id: Option<Uuid>,
847 #[serde(default)]
849 pub auto_post_depreciation: bool,
850 #[serde(default)]
852 pub auto_post_revenue_recognition: bool,
853}
854
855#[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#[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#[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
899pub const FX_REVALUATION_REFERENCE: &str = "fx_revaluation";
909
910#[derive(Debug, Clone, Serialize, Deserialize)]
912pub struct RevaluationLine {
913 pub account_id: Uuid,
915 pub account_number: String,
917 pub account_name: String,
919 pub currency: CurrencyCode,
921 pub normal_balance: BalanceSide,
923 pub foreign_balance: Decimal,
926 pub carrying_value: Decimal,
928 pub rate: Decimal,
930 pub revalued_value: Decimal,
932 pub adjustment: Decimal,
934 pub unrealized_gain_loss: Decimal,
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct RevaluationResult {
941 pub as_of_date: NaiveDate,
943 pub base_currency: CurrencyCode,
945 pub total_unrealized_gain_loss: Decimal,
947 pub lines: Vec<RevaluationLine>,
949 pub journal_entry: Option<JournalEntry>,
952}
953
954#[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 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#[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 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#[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#[must_use]
1067pub fn generate_period_name(year: i32, month: i32) -> String {
1068 format!("{year}-{month:02}")
1069}
1070
1071#[must_use]
1073pub fn create_default_chart_of_accounts() -> Vec<CreateGlAccount> {
1074 vec![
1075 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 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 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 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 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 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 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), compute_revaluation_line(&liability, dec!(500), dec!(1.20), 2), ];
1305 let je_lines = build_revaluation_journal_lines(&lines, fx_account);
1306
1307 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}