Skip to main content

qualia_core_db/specialized_libs/computational_economics/
accounting.rs

1//! Double-entry accounting kernel.
2//!
3//! Amounts are signed integer minor units (for example cents). Posting debits
4//! and credits must be finite ledger amounts: non-negative, one-sided, and
5//! balanced per journal entry.
6
7#[repr(u8)]
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum AccountType {
10    Asset = 0,
11    Liability = 1,
12    Equity = 2,
13    Revenue = 3,
14    Expense = 4,
15}
16
17impl AccountType {
18    pub const fn is_debit_normal(self) -> bool {
19        matches!(self, AccountType::Asset | AccountType::Expense)
20    }
21}
22
23#[repr(C)]
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct Account {
26    pub id: u64,
27    pub account_type: AccountType,
28}
29
30#[repr(C)]
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Posting {
33    pub entry_id: u64,
34    pub account_id: u64,
35    pub debit: i64,
36    pub credit: i64,
37}
38
39#[repr(C)]
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct JournalEntry {
42    pub id: u64,
43    pub posting_start: usize,
44    pub posting_len: usize,
45}
46
47#[repr(C)]
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct AccountBalance {
50    pub account_id: u64,
51    pub account_type: AccountType,
52    /// Positive values are favorable to the account's normal balance side.
53    ///
54    /// Assets and expenses compute `debits - credits`; liabilities, equity,
55    /// and revenue compute `credits - debits`.
56    pub balance: i128,
57    pub debit_total: i128,
58    pub credit_total: i128,
59}
60
61#[repr(C)]
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct TrialBalance {
64    pub total_debits: i128,
65    pub total_credits: i128,
66    pub debit_balance_total: i128,
67    pub credit_balance_total: i128,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum AccountingError {
72    InvalidPosting,
73    EntryNotFound,
74    UnbalancedEntry {
75        entry_id: u64,
76        total_debits: i128,
77        total_credits: i128,
78    },
79    UnknownAccount {
80        account_id: u64,
81    },
82    OutputBufferTooSmall,
83}
84
85fn validate_posting_amounts(posting: &Posting) -> Result<(), AccountingError> {
86    if posting.debit < 0 || posting.credit < 0 {
87        return Err(AccountingError::InvalidPosting);
88    }
89    if (posting.debit == 0 && posting.credit == 0) || (posting.debit > 0 && posting.credit > 0) {
90        return Err(AccountingError::InvalidPosting);
91    }
92    Ok(())
93}
94
95fn find_account(accounts: &[Account], account_id: u64) -> Option<Account> {
96    for account in accounts {
97        if account.id == account_id {
98            return Some(*account);
99        }
100    }
101    None
102}
103
104fn validate_known_accounts(
105    accounts: &[Account],
106    postings: &[Posting],
107) -> Result<(), AccountingError> {
108    for posting in postings {
109        validate_posting_amounts(posting)?;
110        if find_account(accounts, posting.account_id).is_none() {
111            return Err(AccountingError::UnknownAccount {
112                account_id: posting.account_id,
113            });
114        }
115    }
116    Ok(())
117}
118
119/// Validate that all postings belonging to `entry_id` form a balanced entry.
120pub fn validate_balanced_entry(entry_id: u64, postings: &[Posting]) -> Result<(), AccountingError> {
121    let mut total_debits = 0i128;
122    let mut total_credits = 0i128;
123    let mut found = false;
124
125    for posting in postings {
126        if posting.entry_id != entry_id {
127            continue;
128        }
129        validate_posting_amounts(posting)?;
130        found = true;
131        total_debits += posting.debit as i128;
132        total_credits += posting.credit as i128;
133    }
134
135    if !found {
136        return Err(AccountingError::EntryNotFound);
137    }
138    if total_debits != total_credits {
139        return Err(AccountingError::UnbalancedEntry {
140            entry_id,
141            total_debits,
142            total_credits,
143        });
144    }
145    Ok(())
146}
147
148/// Validate a journal entry's declared posting slice and balance.
149pub fn validate_journal_entry(
150    entry: &JournalEntry,
151    postings: &[Posting],
152) -> Result<(), AccountingError> {
153    let end = entry
154        .posting_start
155        .checked_add(entry.posting_len)
156        .ok_or(AccountingError::InvalidPosting)?;
157    if entry.posting_len == 0 || end > postings.len() {
158        return Err(AccountingError::InvalidPosting);
159    }
160
161    let slice = &postings[entry.posting_start..end];
162    for posting in slice {
163        if posting.entry_id != entry.id {
164            return Err(AccountingError::InvalidPosting);
165        }
166    }
167    validate_balanced_entry(entry.id, slice)
168}
169
170/// Validate all declared journal entries.
171pub fn validate_journal_entries(
172    entries: &[JournalEntry],
173    postings: &[Posting],
174) -> Result<(), AccountingError> {
175    for entry in entries {
176        validate_journal_entry(entry, postings)?;
177    }
178    Ok(())
179}
180
181/// Compute balances for every supplied account into caller-owned output.
182pub fn account_balances_into(
183    accounts: &[Account],
184    postings: &[Posting],
185    out: &mut [AccountBalance],
186) -> Result<usize, AccountingError> {
187    if out.len() < accounts.len() {
188        return Err(AccountingError::OutputBufferTooSmall);
189    }
190    validate_known_accounts(accounts, postings)?;
191
192    for (idx, account) in accounts.iter().enumerate() {
193        let mut debit_total = 0i128;
194        let mut credit_total = 0i128;
195
196        for posting in postings {
197            if posting.account_id == account.id {
198                debit_total += posting.debit as i128;
199                credit_total += posting.credit as i128;
200            }
201        }
202
203        let balance = if account.account_type.is_debit_normal() {
204            debit_total - credit_total
205        } else {
206            credit_total - debit_total
207        };
208
209        out[idx] = AccountBalance {
210            account_id: account.id,
211            account_type: account.account_type,
212            balance,
213            debit_total,
214            credit_total,
215        };
216    }
217
218    Ok(accounts.len())
219}
220
221/// Aggregate posting totals and account-side trial-balance totals.
222pub fn trial_balance(
223    accounts: &[Account],
224    postings: &[Posting],
225) -> Result<TrialBalance, AccountingError> {
226    validate_known_accounts(accounts, postings)?;
227
228    let mut total_debits = 0i128;
229    let mut total_credits = 0i128;
230    for posting in postings {
231        total_debits += posting.debit as i128;
232        total_credits += posting.credit as i128;
233    }
234    if total_debits != total_credits {
235        return Err(AccountingError::UnbalancedEntry {
236            entry_id: 0,
237            total_debits,
238            total_credits,
239        });
240    }
241
242    let mut debit_balance_total = 0i128;
243    let mut credit_balance_total = 0i128;
244    for account in accounts {
245        let mut debit_total = 0i128;
246        let mut credit_total = 0i128;
247
248        for posting in postings {
249            if posting.account_id == account.id {
250                debit_total += posting.debit as i128;
251                credit_total += posting.credit as i128;
252            }
253        }
254
255        let raw_debit_balance = debit_total - credit_total;
256        if raw_debit_balance >= 0 {
257            debit_balance_total += raw_debit_balance;
258        } else {
259            credit_balance_total += -raw_debit_balance;
260        }
261    }
262
263    Ok(TrialBalance {
264        total_debits,
265        total_credits,
266        debit_balance_total,
267        credit_balance_total,
268    })
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    const CASH: u64 = 1;
276    const PAYABLE: u64 = 2;
277    const EQUITY: u64 = 3;
278    const REVENUE: u64 = 4;
279    const EXPENSE: u64 = 5;
280
281    fn accounts() -> [Account; 5] {
282        [
283            Account {
284                id: CASH,
285                account_type: AccountType::Asset,
286            },
287            Account {
288                id: PAYABLE,
289                account_type: AccountType::Liability,
290            },
291            Account {
292                id: EQUITY,
293                account_type: AccountType::Equity,
294            },
295            Account {
296                id: REVENUE,
297                account_type: AccountType::Revenue,
298            },
299            Account {
300                id: EXPENSE,
301                account_type: AccountType::Expense,
302            },
303        ]
304    }
305
306    #[test]
307    fn balanced_entry_is_accepted() {
308        let postings = [
309            Posting {
310                entry_id: 10,
311                account_id: CASH,
312                debit: 1_000,
313                credit: 0,
314            },
315            Posting {
316                entry_id: 10,
317                account_id: REVENUE,
318                debit: 0,
319                credit: 1_000,
320            },
321        ];
322
323        assert_eq!(validate_balanced_entry(10, &postings), Ok(()));
324    }
325
326    #[test]
327    fn unbalanced_entry_is_rejected() {
328        let postings = [
329            Posting {
330                entry_id: 11,
331                account_id: CASH,
332                debit: 1_000,
333                credit: 0,
334            },
335            Posting {
336                entry_id: 11,
337                account_id: REVENUE,
338                debit: 0,
339                credit: 900,
340            },
341        ];
342
343        assert_eq!(
344            validate_balanced_entry(11, &postings),
345            Err(AccountingError::UnbalancedEntry {
346                entry_id: 11,
347                total_debits: 1_000,
348                total_credits: 900,
349            })
350        );
351    }
352
353    #[test]
354    fn account_balance_signs_follow_normal_balance_type() {
355        let ledger_accounts = accounts();
356        let postings = [
357            Posting {
358                entry_id: 20,
359                account_id: CASH,
360                debit: 1_000,
361                credit: 0,
362            },
363            Posting {
364                entry_id: 20,
365                account_id: REVENUE,
366                debit: 0,
367                credit: 1_000,
368            },
369            Posting {
370                entry_id: 21,
371                account_id: EXPENSE,
372                debit: 250,
373                credit: 0,
374            },
375            Posting {
376                entry_id: 21,
377                account_id: CASH,
378                debit: 0,
379                credit: 250,
380            },
381            Posting {
382                entry_id: 22,
383                account_id: PAYABLE,
384                debit: 0,
385                credit: 400,
386            },
387            Posting {
388                entry_id: 22,
389                account_id: EXPENSE,
390                debit: 400,
391                credit: 0,
392            },
393            Posting {
394                entry_id: 23,
395                account_id: CASH,
396                debit: 600,
397                credit: 0,
398            },
399            Posting {
400                entry_id: 23,
401                account_id: EQUITY,
402                debit: 0,
403                credit: 600,
404            },
405        ];
406        let mut out = [AccountBalance {
407            account_id: 0,
408            account_type: AccountType::Asset,
409            balance: 0,
410            debit_total: 0,
411            credit_total: 0,
412        }; 5];
413
414        assert_eq!(
415            account_balances_into(&ledger_accounts, &postings, &mut out),
416            Ok(5)
417        );
418        assert_eq!(out[0].balance, 1_350);
419        assert_eq!(out[1].balance, 400);
420        assert_eq!(out[2].balance, 600);
421        assert_eq!(out[3].balance, 1_000);
422        assert_eq!(out[4].balance, 650);
423    }
424
425    #[test]
426    fn trial_balance_totals_match() {
427        let ledger_accounts = accounts();
428        let postings = [
429            Posting {
430                entry_id: 30,
431                account_id: CASH,
432                debit: 1_000,
433                credit: 0,
434            },
435            Posting {
436                entry_id: 30,
437                account_id: REVENUE,
438                debit: 0,
439                credit: 1_000,
440            },
441            Posting {
442                entry_id: 31,
443                account_id: EXPENSE,
444                debit: 300,
445                credit: 0,
446            },
447            Posting {
448                entry_id: 31,
449                account_id: CASH,
450                debit: 0,
451                credit: 300,
452            },
453        ];
454
455        assert_eq!(
456            trial_balance(&ledger_accounts, &postings),
457            Ok(TrialBalance {
458                total_debits: 1_300,
459                total_credits: 1_300,
460                debit_balance_total: 1_000,
461                credit_balance_total: 1_000,
462            })
463        );
464    }
465
466    #[test]
467    fn unknown_account_is_rejected() {
468        let ledger_accounts = accounts();
469        let postings = [Posting {
470            entry_id: 40,
471            account_id: 99,
472            debit: 100,
473            credit: 0,
474        }];
475        let mut out = [AccountBalance {
476            account_id: 0,
477            account_type: AccountType::Asset,
478            balance: 0,
479            debit_total: 0,
480            credit_total: 0,
481        }; 5];
482
483        assert_eq!(
484            account_balances_into(&ledger_accounts, &postings, &mut out),
485            Err(AccountingError::UnknownAccount { account_id: 99 })
486        );
487        assert_eq!(
488            trial_balance(&ledger_accounts, &postings),
489            Err(AccountingError::UnknownAccount { account_id: 99 })
490        );
491    }
492
493    #[test]
494    fn output_buffer_too_small_is_rejected() {
495        let ledger_accounts = accounts();
496        let postings = [Posting {
497            entry_id: 50,
498            account_id: CASH,
499            debit: 100,
500            credit: 0,
501        }];
502        let mut out = [AccountBalance {
503            account_id: 0,
504            account_type: AccountType::Asset,
505            balance: 0,
506            debit_total: 0,
507            credit_total: 0,
508        }; 4];
509
510        assert_eq!(
511            account_balances_into(&ledger_accounts, &postings, &mut out),
512            Err(AccountingError::OutputBufferTooSmall)
513        );
514    }
515
516    #[test]
517    fn journal_entry_range_validation_checks_ids_and_balance() {
518        let postings = [
519            Posting {
520                entry_id: 60,
521                account_id: CASH,
522                debit: 100,
523                credit: 0,
524            },
525            Posting {
526                entry_id: 60,
527                account_id: REVENUE,
528                debit: 0,
529                credit: 100,
530            },
531        ];
532        let entry = JournalEntry {
533            id: 60,
534            posting_start: 0,
535            posting_len: 2,
536        };
537        assert_eq!(validate_journal_entry(&entry, &postings), Ok(()));
538    }
539
540    #[test]
541    fn journal_entry_rejects_wrong_posting_id_in_range() {
542        let postings = [
543            Posting {
544                entry_id: 70,
545                account_id: CASH,
546                debit: 100,
547                credit: 0,
548            },
549            Posting {
550                entry_id: 71,
551                account_id: REVENUE,
552                debit: 0,
553                credit: 100,
554            },
555        ];
556        let entry = JournalEntry {
557            id: 70,
558            posting_start: 0,
559            posting_len: 2,
560        };
561        assert_eq!(
562            validate_journal_entry(&entry, &postings),
563            Err(AccountingError::InvalidPosting)
564        );
565    }
566}