1#[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 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
119pub 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
148pub 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
170pub 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
181pub 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
221pub 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}