├── .github └── workflows │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE.txt ├── README.md └── src ├── account.rs ├── actions.rs ├── error.rs ├── lib.rs ├── program.rs └── snapshots ├── doublecount__account__serde_tests__account_serde.snap ├── doublecount__actions__serde_tests__balance_assertion_serde.snap ├── doublecount__actions__serde_tests__edit_account_status_serde.snap ├── doublecount__actions__serde_tests__transaction_serde.snap └── doublecount__program__tests__program_serde.snap /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | name: Test Suite 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions-rs/toolchain@v1 16 | with: 17 | profile: minimal 18 | toolchain: stable 19 | override: true 20 | - uses: actions-rs/cargo@v1 21 | with: 22 | command: test 23 | args: --all-features 24 | 25 | fmt: 26 | name: Rustfmt 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v2 30 | - uses: actions-rs/toolchain@v1 31 | with: 32 | profile: minimal 33 | toolchain: stable 34 | override: true 35 | - run: rustup component add rustfmt 36 | - uses: actions-rs/cargo@v1 37 | with: 38 | command: fmt 39 | args: --all -- --check 40 | 41 | clippy: 42 | name: Clippy 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v1 46 | - uses: actions-rs/toolchain@v1 47 | with: 48 | toolchain: nightly 49 | components: clippy 50 | override: true 51 | # Note that there is no release tag available yet 52 | # and the following code will use master branch HEAD 53 | # all the time. 54 | - uses: actions-rs/clippy@master 55 | with: 56 | args: --all-features -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | 4 | # Visual Studio Code 5 | .vscode -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.8.2 4 | 5 | + Update `commodity` to version `0.4`. 6 | 7 | ## v0.8.1 8 | 9 | + Fix incomplete `Action` implementation for `BalanceAssertion`. 10 | 11 | ## v0.8.0 12 | 13 | + Refactor to properly support user defined `Action`s. This included making `Program` and a number of other types generic over `ActionTypeValueEnum` and `ActionTypeEnum` implementations. These changes shouldn't affect anyone using the provided set of `Actions`. 14 | + Introduced a new trait `ActionTypeFor` which has some functionality separated from `Action`. This is a breaking change for people implementing `Action` themselves. 15 | 16 | ## v0.7.1 17 | 18 | + Fix building for default features accidentally including some items only needed for `serde-support`. 19 | 20 | ## v0.7.0 21 | 22 | + Complete `serde` serialization for `Program`, and the various `Action` implementations. [#2](https://github.com/kellpossible/doublecount/issues/2). 23 | + Refactor `Program` to use an enum `ActionTypeValue` instead of dynamic trait dispatch over `Action`. Programs using custom actions need to create their own implementation of `ActionTypeValueEnum` to provide store their actions given to `Program`. 24 | 25 | ## v0.6.2 26 | 27 | + Fix changelog fo `v0.6.1` 28 | 29 | ## v0.6.1 30 | 31 | + Bump `rust_decimal` dependency up to using generic version `1` to address [#5](https://github.com/kellpossible/doublecount/issues/5). 32 | + Update `Account#new()`, `Transaction#new()` and `Transaction#new_simple()` to use `Into` to address [#4](https://github.com/kellpossible/doublecount/issues/4). 33 | 34 | ## v0.6.0 35 | 36 | + Renamed argument in `sum_account_states()`, `sum_currency` to 37 | `sum_commodity_type_id` to better match the recent changes in `commodity` library. 38 | 39 | ## v0.5.0 40 | 41 | + Updated `commodity` library dependency to `v0.3.0`, renamed some types 42 | which were changed for that version 43 | + `AccountingError::Currency` renamed to `AccountingError::Commodity` 44 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "doublecount" 3 | version = "0.8.2" 4 | description = "A double entry accounting system/library." 5 | keywords = ["financial", "currency", "accounting", "exchange", "rate"] 6 | categories = ["science", "mathematics"] 7 | authors = ["Luke Frisken "] 8 | edition = "2018" 9 | license = "MIT" 10 | repository = "https://github.com/kellpossible/doublecount" 11 | readme = "README.md" 12 | 13 | [badges] 14 | maintenance = { status = "actively-developed" } 15 | 16 | [features] 17 | default = [] 18 | serde-support = ["commodity/serde-support", "rust_decimal/serde", "serde", "serde_derive"] 19 | 20 | [dependencies] 21 | chrono = "0.4" 22 | nanoid = "0.3.0" 23 | thiserror = "1.0" 24 | rust_decimal = { version = "1", default-features = false } 25 | commodity = "0.4" 26 | serde_derive = { version = "1.0", optional = true} 27 | serde = { version = "1.0", optional = true, features = ["derive"] } 28 | arrayvec = "0.5" 29 | 30 | [dev-dependencies] 31 | serde_json = { version = "1.0" } # for unit tests 32 | doc-comment = "0.3" 33 | insta = "0.16.0" 34 | 35 | [package.metadata.docs.rs] 36 | features = ["serde-support"] 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 Luke Frisken 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | © 2020 Luke Frisken -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Doublecount [![crates.io badge](https://img.shields.io/crates/v/doublecount.svg)](https://crates.io/crates/doublecount) [![docs.rs badge](https://docs.rs/doublecount/badge.svg)](https://docs.rs/doublecount/) [![license badge](https://img.shields.io/github/license/kellpossible/doublecount)](https://github.com/kellpossible/doublecount/blob/master/LICENSE.txt) [![github action badge](https://github.com/kellpossible/doublecount/workflows/Rust/badge.svg)](https://github.com/kellpossible/doublecount/actions?query=workflow%3ARust) 2 | 3 | A double entry accounting system/library for Rust. 4 | 5 | This project is very much inspired by [beancount](http://furius.ca/beancount/), 6 | however it currently presents a much simpler model. It has been designed to 7 | embed within other applications for the purpose of running accounting 8 | calculations. 9 | 10 | Commodities within the system are represented using the primitives provided by 11 | the [commodity](https://crates.io/crates/commodity) library, which is in turn 12 | backed by [rust_decimal](https://crates.io/crates/rust_decimal). 13 | 14 | This library is under active development, however it should already be usable 15 | for some simple purposes. There's likely to be some API changes in the future to 16 | allow transactions/actions to be streamed into the system, and also to support 17 | parallel computations of transactions to allow large programs to efficiently 18 | executed on multi-core computers. 19 | 20 | **[Changelog](./CHANGELOG.md)** 21 | 22 | ## Optional Features 23 | 24 | The following features can be enabled to provide extra functionality: 25 | 26 | + `serde-support` 27 | + Enables support for serialization/de-serialization via `serde` 28 | 29 | ## Usage 30 | 31 | ```rust 32 | use doublecount::{ 33 | AccountStatus, EditAccountStatus, Account, Program, Action, 34 | ProgramState, Transaction, TransactionElement, BalanceAssertion, 35 | ActionTypeValue, 36 | }; 37 | use commodity::{CommodityType, Commodity}; 38 | use chrono::NaiveDate; 39 | use std::rc::Rc; 40 | use std::str::FromStr; 41 | 42 | // create a commodity from a currency's iso4317 alphanumeric code 43 | let aud = Rc::from(CommodityType::from_str("AUD", "Australian Dollar").unwrap()); 44 | 45 | // Create a couple of accounts 46 | let account1 = Rc::from(Account::new_with_id(Some("Account 1"), aud.id, None)); 47 | let account2 = Rc::from(Account::new_with_id(Some("Account 2"), aud.id, None)); 48 | 49 | // create a new program state, with accounts starting Closed 50 | let mut program_state = ProgramState::new( 51 | &vec![account1.clone(), account2.clone()], 52 | AccountStatus::Closed 53 | ); 54 | 55 | // open account1 56 | let open_account1 = EditAccountStatus::new( 57 | account1.id, 58 | AccountStatus::Open, 59 | NaiveDate::from_str("2020-01-01").unwrap(), 60 | ); 61 | 62 | // open account2 63 | let open_account2 = EditAccountStatus::new( 64 | account2.id, 65 | AccountStatus::Open, 66 | NaiveDate::from_str("2020-01-01").unwrap(), 67 | ); 68 | 69 | // create a transaction to transfer some commodity 70 | // from account1 to account2. 71 | let transaction1 = Transaction::new( 72 | Some(String::from("Transaction 1")), 73 | NaiveDate::from_str("2020-01-02").unwrap(), 74 | vec![ 75 | TransactionElement::new( 76 | account1.id, 77 | Some(Commodity::from_str("-2.52 AUD").unwrap()), 78 | None, 79 | ), 80 | TransactionElement::new( 81 | account2.id, 82 | Some(Commodity::from_str("2.52 AUD").unwrap()), 83 | None, 84 | ), 85 | ], 86 | ); 87 | 88 | // create a balance assertion (that will cause the program to return an error 89 | // if it fails), to check that the balance of account1 matches the expected 90 | // value of -1.52 AUD at the start of the date of 2020-01-03 91 | let balance_assertion1 = BalanceAssertion::new( 92 | account1.id, 93 | NaiveDate::from_str("2020-01-03").unwrap(), 94 | Commodity::from_str("-2.52 AUD").unwrap() 95 | ); 96 | 97 | // create another transaction to transfer commodity from 98 | // account2 to account1, using the simpler syntax. 99 | let transaction2 = Transaction::new_simple( 100 | Some("Transaction 2"), 101 | NaiveDate::from_str("2020-01-03").unwrap(), 102 | account2.id, 103 | account1.id, 104 | Commodity::from_str("1.0 AUD").unwrap(), 105 | None, 106 | ); 107 | 108 | let balance_assertion2 = BalanceAssertion::new( 109 | account1.id, 110 | NaiveDate::from_str("2020-01-04").unwrap(), 111 | Commodity::from_str("-1.52 AUD").unwrap() 112 | ); 113 | 114 | let balance_assertion3 = BalanceAssertion::new( 115 | account2.id, 116 | NaiveDate::from_str("2020-01-04").unwrap(), 117 | Commodity::from_str("1.52 AUD").unwrap() 118 | ); 119 | 120 | let actions: Vec> = vec![ 121 | Rc::new(open_account1.into()), 122 | Rc::new(open_account2.into()), 123 | Rc::new(transaction1.into()), 124 | Rc::new(balance_assertion1.into()), 125 | Rc::new(transaction2.into()), 126 | Rc::new(balance_assertion2.into()), 127 | Rc::new(balance_assertion3.into()), 128 | ]; 129 | 130 | // create a program from the actions 131 | let program = Program::new(actions); 132 | 133 | // run the program 134 | program_state.execute_program(&program).unwrap(); 135 | ``` 136 | -------------------------------------------------------------------------------- /src/account.rs: -------------------------------------------------------------------------------- 1 | use arrayvec::ArrayString; 2 | use commodity::{Commodity, CommodityTypeID}; 3 | use nanoid::nanoid; 4 | use rust_decimal::Decimal; 5 | use std::rc::Rc; 6 | 7 | #[cfg(feature = "serde-support")] 8 | use serde::{Deserialize, Serialize}; 9 | 10 | /// The size in characters/bytes of the [Account](Account) id. 11 | const ACCOUNT_ID_LENGTH: usize = 20; 12 | 13 | /// The status of an [Account](Account) stored within an [AccountState](AccountState). 14 | #[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))] 15 | #[derive(Copy, Clone, Debug, PartialEq)] 16 | pub enum AccountStatus { 17 | /// The account is open 18 | Open, 19 | /// The account is closed 20 | Closed, 21 | } 22 | /// The type to use for the id of [Account](Account)s. 23 | pub type AccountID = ArrayString<[u8; ACCOUNT_ID_LENGTH]>; 24 | 25 | /// A way to categorize [Account](Account)s. 26 | pub type AccountCategory = String; 27 | 28 | /// Details for an account, which holds a [Commodity](Commodity) 29 | /// with a type of [CommodityType](commodity::CommodityType). 30 | #[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))] 31 | #[derive(Debug, Clone)] 32 | pub struct Account { 33 | /// A unique identifier for this `Account`, currently generated using [nanoid](nanoid). 34 | pub id: AccountID, 35 | 36 | /// The name of this `Account` 37 | pub name: Option, 38 | 39 | /// The id of the type of commodity to be stored in this account 40 | pub commodity_type_id: CommodityTypeID, 41 | 42 | /// The category that this account part of 43 | pub category: Option, 44 | } 45 | 46 | impl Account { 47 | /// Create a new account with an automatically generated id (using 48 | /// [nanoid](nanoid)) and add it to this program state (and create 49 | /// its associated [AccountState](AccountState)). 50 | pub fn new_with_id>( 51 | name: Option, 52 | commodity_type_id: CommodityTypeID, 53 | category: Option, 54 | ) -> Account { 55 | let id_string: String = nanoid!(ACCOUNT_ID_LENGTH); 56 | Self::new( 57 | ArrayString::from(id_string.as_ref()).unwrap_or_else(|_| { 58 | panic!( 59 | "generated id string {0} should fit within ACCOUNT_ID_LENGTH: {1}", 60 | id_string, ACCOUNT_ID_LENGTH 61 | ) 62 | }), 63 | name, 64 | commodity_type_id, 65 | category, 66 | ) 67 | } 68 | 69 | /// Create a new account and add it to this program state (and create its associated 70 | /// [AccountState](AccountState)). 71 | pub fn new>( 72 | id: AccountID, 73 | name: Option, 74 | commodity_type_id: CommodityTypeID, 75 | category: Option, 76 | ) -> Account { 77 | Account { 78 | id, 79 | name: name.map(|s| s.into()), 80 | commodity_type_id, 81 | category, 82 | } 83 | } 84 | } 85 | 86 | impl PartialEq for Account { 87 | fn eq(&self, other: &Account) -> bool { 88 | self.id == other.id 89 | } 90 | } 91 | 92 | /// Mutable state associated with an [Account](Account). 93 | #[derive(Debug, Clone, PartialEq)] 94 | pub struct AccountState { 95 | /// The [Account](Account) associated with this state 96 | pub account: Rc, 97 | 98 | /// The amount of the commodity currently stored in this account 99 | pub amount: Commodity, 100 | 101 | /// The status of this account (open/closed/etc...) 102 | pub status: AccountStatus, 103 | } 104 | 105 | impl AccountState { 106 | /// Create a new [AccountState](AccountState). 107 | pub fn new(account: Rc, amount: Commodity, status: AccountStatus) -> AccountState { 108 | AccountState { 109 | account, 110 | amount, 111 | status, 112 | } 113 | } 114 | 115 | /// Open this account, set the `status` to [Open](AccountStatus::Open) 116 | pub fn open(&mut self) { 117 | self.status = AccountStatus::Open; 118 | } 119 | 120 | // Close this account, set the `status` to [Closed](AccountStatus::Closed) 121 | pub fn close(&mut self) { 122 | self.status = AccountStatus::Closed; 123 | } 124 | 125 | pub fn eq_approx(&self, other: &AccountState, epsilon: Decimal) -> bool { 126 | self.account == other.account 127 | && self.status == other.status 128 | && self.amount.eq_approx(other.amount, epsilon) 129 | } 130 | } 131 | 132 | #[cfg(feature = "serde-support")] 133 | #[cfg(test)] 134 | mod serde_tests { 135 | use super::Account; 136 | use super::AccountID; 137 | use commodity::CommodityTypeID; 138 | use std::str::FromStr; 139 | 140 | #[test] 141 | fn account_serde() { 142 | use serde_json; 143 | 144 | let json = r#"{ 145 | "id": "ABCDEFGHIJKLMNOPQRST", 146 | "name": "Test Account", 147 | "commodity_type_id": "USD", 148 | "category": "Expense" 149 | }"#; 150 | 151 | let account: Account = serde_json::from_str(json).unwrap(); 152 | 153 | let reference_account = Account::new( 154 | AccountID::from("ABCDEFGHIJKLMNOPQRST").unwrap(), 155 | Some("TestAccount"), 156 | CommodityTypeID::from_str("AUD").unwrap(), 157 | Some("Expense".to_string()), 158 | ); 159 | 160 | assert_eq!(reference_account, account); 161 | insta::assert_json_snapshot!(account); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/actions.rs: -------------------------------------------------------------------------------- 1 | use super::{AccountID, AccountStatus, AccountingError, ProgramState}; 2 | use chrono::NaiveDate; 3 | use commodity::exchange_rate::ExchangeRate; 4 | use commodity::Commodity; 5 | use rust_decimal::{prelude::Zero, Decimal}; 6 | use std::fmt; 7 | use std::rc::Rc; 8 | use std::{marker::PhantomData, slice}; 9 | 10 | #[cfg(feature = "serde-support")] 11 | use serde::{Deserialize, Serialize}; 12 | 13 | /// A representation of what type of [Action](Action) is being performed. 14 | #[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Hash, Clone)] 15 | pub enum ActionType { 16 | /// An [Action](Action) to edit the status of an [Account](crate::Account). 17 | /// Represented by the [EditAccountStatus](EditAccountStatus) struct. 18 | /// 19 | /// This action has the highest priority when being sorted, because 20 | /// other actions on the same day may depend on this already having 21 | /// been executed. 22 | EditAccountStatus, 23 | /// An [Action](Action) to assert the current balance of an account while 24 | /// a [Program](super::Program) is being executed. Represented by a 25 | /// [BalanceAssertion](BalanceAssertion) struct. 26 | BalanceAssertion, 27 | /// A [Action](Action) to perform a transaction between [Account](crate::Account)s. 28 | /// Represented by the [Transaction](Transaction) struct. 29 | Transaction, 30 | } 31 | 32 | impl ActionTypeFor for ActionTypeValue { 33 | fn action_type(&self) -> ActionType { 34 | match self { 35 | ActionTypeValue::EditAccountStatus(_) => ActionType::EditAccountStatus, 36 | ActionTypeValue::BalanceAssertion(_) => ActionType::BalanceAssertion, 37 | ActionTypeValue::Transaction(_) => ActionType::Transaction, 38 | } 39 | } 40 | } 41 | 42 | impl ActionType { 43 | /// Return an iterator over all available [ActionType](ActionType) variants. 44 | pub fn iterator() -> slice::Iter<'static, ActionType> { 45 | static ACTION_TYPES: [ActionType; 3] = [ 46 | ActionType::EditAccountStatus, 47 | ActionType::BalanceAssertion, 48 | ActionType::Transaction, 49 | ]; 50 | ACTION_TYPES.iter() 51 | } 52 | } 53 | 54 | /// A trait which represents an enum/sized data structure which is 55 | /// capable of storing every possible concrete implementation of 56 | /// [Action](Action) for your [Program](crate::Program). 57 | /// 58 | /// If you have some custom actions, you need to implement this trait 59 | /// yourself and use it to store your actions that you provide to 60 | /// [Program](crate::Program). 61 | pub trait ActionTypeValueEnum { 62 | fn as_action(&self) -> &dyn Action; 63 | } 64 | 65 | /// An enum to store every possible concrete implementation of 66 | /// [Action](Action) in a `Sized` element. 67 | #[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))] 68 | #[cfg_attr(feature = "serde-support", serde(tag = "type"))] 69 | #[derive(Debug, Clone, PartialEq)] 70 | pub enum ActionTypeValue { 71 | EditAccountStatus(EditAccountStatus), 72 | BalanceAssertion(BalanceAssertion), 73 | Transaction(Transaction), 74 | } 75 | 76 | impl ActionTypeValueEnum for ActionTypeValue { 77 | fn as_action(&self) -> &dyn Action { 78 | match self { 79 | ActionTypeValue::EditAccountStatus(action) => action, 80 | ActionTypeValue::BalanceAssertion(action) => action, 81 | ActionTypeValue::Transaction(action) => action, 82 | } 83 | } 84 | } 85 | 86 | impl From for ActionTypeValue { 87 | fn from(action: EditAccountStatus) -> Self { 88 | ActionTypeValue::EditAccountStatus(action) 89 | } 90 | } 91 | 92 | impl From for ActionTypeValue { 93 | fn from(action: BalanceAssertion) -> Self { 94 | ActionTypeValue::BalanceAssertion(action) 95 | } 96 | } 97 | 98 | impl From for ActionTypeValue { 99 | fn from(action: Transaction) -> Self { 100 | ActionTypeValue::Transaction(action) 101 | } 102 | } 103 | 104 | /// Obtain the concrete action type for an action. 105 | pub trait ActionTypeFor { 106 | /// What type of action is being performed. 107 | fn action_type(&self) -> AT; 108 | } 109 | 110 | /// Represents an action which can modify [ProgramState](ProgramState). 111 | pub trait Action: fmt::Display + fmt::Debug { 112 | /// The date/time (in the account history) that the action was performed. 113 | fn date(&self) -> NaiveDate; 114 | 115 | /// Perform the action to mutate the [ProgramState](ProgramState). 116 | fn perform(&self, program_state: &mut ProgramState) -> Result<(), AccountingError>; 117 | } 118 | 119 | /// A way to sort [Action](Action)s by their date, and then by the 120 | /// priority of their [ActionType](ActionType). 121 | /// 122 | /// # Example 123 | /// ``` 124 | /// use doublecount::{ActionTypeValue, ActionOrder}; 125 | /// use std::rc::Rc; 126 | /// 127 | /// let mut actions: Vec> = Vec::new(); 128 | /// 129 | /// // let's pretend we created and added 130 | /// // some actions to the actions vector 131 | /// 132 | /// // sort the actions using this order 133 | /// actions.sort_by_key(|a| ActionOrder::new(a.clone())); 134 | /// ``` 135 | pub struct ActionOrder { 136 | action_value: Rc, 137 | action_type: PhantomData, 138 | } 139 | 140 | impl ActionOrder { 141 | pub fn new(action_value: Rc) -> Self { 142 | Self { 143 | action_value, 144 | action_type: PhantomData::default(), 145 | } 146 | } 147 | } 148 | 149 | impl PartialEq for ActionOrder 150 | where 151 | AT: PartialEq, 152 | ATV: ActionTypeValueEnum + ActionTypeFor, 153 | { 154 | fn eq(&self, other: &ActionOrder) -> bool { 155 | let self_action = self.action_value.as_action(); 156 | let other_action = other.action_value.as_action(); 157 | self.action_value.action_type() == other.action_value.action_type() 158 | && self_action.date() == other_action.date() 159 | } 160 | } 161 | 162 | impl Eq for ActionOrder 163 | where 164 | ATV: ActionTypeValueEnum + ActionTypeFor, 165 | AT: PartialEq, 166 | { 167 | } 168 | 169 | impl PartialOrd for ActionOrder 170 | where 171 | AT: Ord, 172 | ATV: ActionTypeValueEnum + ActionTypeFor, 173 | { 174 | fn partial_cmp(&self, other: &Self) -> Option { 175 | let self_action = self.action_value.as_action(); 176 | let other_action = other.action_value.as_action(); 177 | self_action 178 | .date() 179 | .partial_cmp(&other_action.date()) 180 | .map(|date_order| { 181 | date_order.then( 182 | self.action_value 183 | .action_type() 184 | .cmp(&other.action_value.action_type()), 185 | ) 186 | }) 187 | } 188 | } 189 | 190 | impl Ord for ActionOrder 191 | where 192 | AT: Ord, 193 | ATV: ActionTypeValueEnum + ActionTypeFor, 194 | { 195 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 196 | let self_action = self.action_value.as_action(); 197 | let other_action = other.action_value.as_action(); 198 | self_action.date().cmp(&other_action.date()).then( 199 | self.action_value 200 | .action_type() 201 | .cmp(&other.action_value.action_type()), 202 | ) 203 | } 204 | } 205 | 206 | /// A movement of [Commodity](Commodity) between two or more accounts 207 | /// on a given `date`. Implements [Action](Action) so it can be 208 | /// applied to change [AccountState](super::AccountState)s. 209 | /// 210 | /// The sum of the [Commodity](Commodity) `amount`s contained within a 211 | /// transaction's [TransactionElement](TransactionElement)s needs to 212 | /// be equal to zero, or one of the elements needs to have a `None` 213 | /// value `amount`. 214 | #[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))] 215 | #[derive(Debug, Clone, PartialEq)] 216 | pub struct Transaction { 217 | /// Description of this transaction. 218 | pub description: Option, 219 | /// The date that the transaction occurred. 220 | pub date: NaiveDate, 221 | /// Elements which compose this transaction. 222 | /// 223 | /// See [Transaction](Transaction) for more information about the 224 | /// constraints which apply to this field. 225 | pub elements: Vec, 226 | } 227 | 228 | impl Transaction { 229 | /// Create a new [Transaction](Transaction). 230 | pub fn new>( 231 | description: Option, 232 | date: NaiveDate, 233 | elements: Vec, 234 | ) -> Transaction { 235 | Transaction { 236 | description: description.map(|s| s.into()), 237 | date, 238 | elements, 239 | } 240 | } 241 | 242 | /// Create a new simple [Transaction](Transaction), containing 243 | /// only two elements, transfering an `amount` from `from_account` 244 | /// to `to_account` on the given `date`, with the given 245 | /// `exchange_rate` (required if the currencies of the accounts 246 | /// are different). 247 | /// 248 | /// # Example 249 | /// ``` 250 | /// # use doublecount::Transaction; 251 | /// # use std::rc::Rc; 252 | /// use doublecount::Account; 253 | /// use commodity::{CommodityType, Commodity}; 254 | /// use chrono::Local; 255 | /// use std::str::FromStr; 256 | /// 257 | /// let aud = Rc::from(CommodityType::from_str("AUD", "Australian Dollar").unwrap()); 258 | /// 259 | /// let account1 = Rc::from(Account::new_with_id(Some("Account 1"), aud.id, None)); 260 | /// let account2 = Rc::from(Account::new_with_id(Some("Account 2"), aud.id, None)); 261 | /// 262 | /// let transaction = Transaction::new_simple( 263 | /// Some("balancing"), 264 | /// Local::today().naive_local(), 265 | /// account1.id, 266 | /// account2.id, 267 | /// Commodity::from_str("100.0 AUD").unwrap(), 268 | /// None, 269 | /// ); 270 | /// 271 | /// assert_eq!(2, transaction.elements.len()); 272 | /// let element0 = transaction.elements.get(0).unwrap(); 273 | /// let element1 = transaction.elements.get(1).unwrap(); 274 | /// assert_eq!(Some(Commodity::from_str("-100.0 AUD").unwrap()), element0.amount); 275 | /// assert_eq!(account1.id, element0.account_id); 276 | /// assert_eq!(account2.id, element1.account_id); 277 | /// assert_eq!(None, element1.amount); 278 | /// ``` 279 | pub fn new_simple>( 280 | description: Option, 281 | date: NaiveDate, 282 | from_account: AccountID, 283 | to_account: AccountID, 284 | amount: Commodity, 285 | exchange_rate: Option, 286 | ) -> Transaction { 287 | Transaction::new( 288 | description, 289 | date, 290 | vec![ 291 | TransactionElement::new(from_account, Some(amount.neg()), exchange_rate.clone()), 292 | TransactionElement::new(to_account, None, exchange_rate), 293 | ], 294 | ) 295 | } 296 | 297 | /// Get the [TransactionElement](TransactionElement) associated 298 | /// with the given [Account](crate::Account)'s id. 299 | pub fn get_element(&self, account_id: &AccountID) -> Option<&TransactionElement> { 300 | self.elements.iter().find(|e| &e.account_id == account_id) 301 | } 302 | } 303 | 304 | impl ActionTypeFor for Transaction { 305 | fn action_type(&self) -> ActionType { 306 | todo!() 307 | } 308 | } 309 | 310 | impl fmt::Display for Transaction { 311 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 312 | write!(f, "Transaction") 313 | } 314 | } 315 | 316 | impl Action for Transaction 317 | where 318 | ATV: ActionTypeValueEnum, 319 | { 320 | fn date(&self) -> NaiveDate { 321 | self.date 322 | } 323 | 324 | fn perform(&self, program_state: &mut ProgramState) -> Result<(), AccountingError> { 325 | // check that the transaction has at least 2 elements 326 | if self.elements.len() < 2 { 327 | return Err(AccountingError::InvalidTransaction( 328 | self.clone(), 329 | String::from("a transaction cannot have less than 2 elements"), 330 | )); 331 | } 332 | 333 | //TODO: add check to ensure that transaction doesn't have duplicate account references? 334 | 335 | // first process the elements to automatically calculate amounts 336 | 337 | let mut empty_amount_element: Option = None; 338 | for (i, element) in self.elements.iter().enumerate() { 339 | if element.amount.is_none() { 340 | if empty_amount_element.is_none() { 341 | empty_amount_element = Some(i) 342 | } else { 343 | return Err(AccountingError::InvalidTransaction( 344 | self.clone(), 345 | String::from("multiple elements with no amount specified"), 346 | )); 347 | } 348 | } 349 | } 350 | 351 | let sum_commodity_type_id = match empty_amount_element { 352 | Some(empty_i) => { 353 | let empty_element = self.elements.get(empty_i).unwrap(); 354 | 355 | match program_state.get_account(&empty_element.account_id) { 356 | Some(account) => account.commodity_type_id, 357 | None => { 358 | return Err(AccountingError::MissingAccountState( 359 | empty_element.account_id, 360 | )) 361 | } 362 | } 363 | } 364 | None => { 365 | let account_id = self 366 | .elements 367 | .get(0) 368 | .expect("there should be at least 2 elements in the transaction") 369 | .account_id; 370 | 371 | match program_state.get_account(&account_id) { 372 | Some(account) => account.commodity_type_id, 373 | None => return Err(AccountingError::MissingAccountState(account_id)), 374 | } 375 | } 376 | }; 377 | 378 | let mut sum = Commodity::new(Decimal::zero(), sum_commodity_type_id); 379 | 380 | let mut modified_elements = self.elements.clone(); 381 | 382 | // Calculate the sum of elements (not including the empty element if there is one) 383 | for (i, element) in self.elements.iter().enumerate() { 384 | if let Some(empty_i) = empty_amount_element { 385 | if i != empty_i { 386 | //TODO: perform commodity type conversion here if required 387 | sum = match sum.add(&element.amount.as_ref().unwrap()) { 388 | Ok(value) => value, 389 | Err(error) => return Err(AccountingError::Commodity(error)), 390 | } 391 | } 392 | } 393 | } 394 | 395 | // Calculate the value to use for the empty element (negate the sum of the other elements) 396 | if let Some(empty_i) = empty_amount_element { 397 | let modified_emtpy_element: &mut TransactionElement = 398 | modified_elements.get_mut(empty_i).unwrap(); 399 | let negated_sum = sum.neg(); 400 | modified_emtpy_element.amount = Some(negated_sum); 401 | 402 | sum = match sum.add(&negated_sum) { 403 | Ok(value) => value, 404 | Err(error) => return Err(AccountingError::Commodity(error)), 405 | } 406 | } 407 | 408 | if sum.value != Decimal::zero() { 409 | return Err(AccountingError::InvalidTransaction( 410 | self.clone(), 411 | String::from("sum of transaction elements does not equal zero"), 412 | )); 413 | } 414 | 415 | for transaction in &modified_elements { 416 | let mut account_state = program_state 417 | .get_account_state_mut(&transaction.account_id) 418 | .unwrap_or_else(|| 419 | panic!( 420 | "unable to find state for account with id: {} please ensure this account was added to the program state before execution.", 421 | transaction.account_id 422 | ) 423 | ); 424 | 425 | match account_state.status { 426 | AccountStatus::Closed => Err(AccountingError::InvalidAccountStatus { 427 | account_id: transaction.account_id, 428 | status: account_state.status, 429 | }), 430 | _ => Ok(()), 431 | }?; 432 | 433 | // TODO: perform the commodity type conversion using the exchange rate (if present) 434 | 435 | let transaction_amount = match &transaction.amount { 436 | Some(amount) => amount, 437 | None => { 438 | return Err(AccountingError::InvalidTransaction( 439 | self.clone(), 440 | String::from( 441 | "unable to calculate all required amounts for this transaction", 442 | ), 443 | )) 444 | } 445 | }; 446 | 447 | account_state.amount = match account_state.amount.add(transaction_amount) { 448 | Ok(commodity) => commodity, 449 | Err(err) => { 450 | return Err(AccountingError::Commodity(err)); 451 | } 452 | } 453 | } 454 | 455 | Ok(()) 456 | } 457 | } 458 | 459 | /// An element of a [Transaction](Transaction). 460 | #[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))] 461 | #[derive(Debug, Clone, PartialEq)] 462 | pub struct TransactionElement { 463 | /// The account to perform the transaction to 464 | pub account_id: AccountID, 465 | 466 | /// The amount of [Commodity](Commodity) to add to the account. 467 | /// 468 | /// This may be `None`, if it is the only element within a 469 | /// [Transaction](Transaction), which is None. If it is `None`, 470 | /// it's amount will be automatically calculated from the amounts 471 | /// in the other elements present in the transaction. 472 | pub amount: Option, 473 | 474 | /// The exchange rate to use for converting the amount in this element 475 | /// to a different [CommodityType](commodity::CommodityType). 476 | pub exchange_rate: Option, 477 | } 478 | 479 | impl TransactionElement { 480 | /// Create a new [TransactionElement](TransactionElement). 481 | pub fn new( 482 | account_id: AccountID, 483 | amount: Option, 484 | exchange_rate: Option, 485 | ) -> TransactionElement { 486 | TransactionElement { 487 | account_id, 488 | amount, 489 | exchange_rate, 490 | } 491 | } 492 | } 493 | 494 | /// A type of [Action](Action) to edit the 495 | /// [AccountStatus](AccountStatus) of a given [Account](crate::Account)'s 496 | /// [AccountState](super::AccountState). 497 | #[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))] 498 | #[derive(Debug, Clone, PartialEq)] 499 | pub struct EditAccountStatus { 500 | account_id: AccountID, 501 | newstatus: AccountStatus, 502 | date: NaiveDate, 503 | } 504 | 505 | impl EditAccountStatus { 506 | /// Create a new [EditAccountStatus](EditAccountStatus). 507 | pub fn new( 508 | account_id: AccountID, 509 | newstatus: AccountStatus, 510 | date: NaiveDate, 511 | ) -> EditAccountStatus { 512 | EditAccountStatus { 513 | account_id, 514 | newstatus, 515 | date, 516 | } 517 | } 518 | } 519 | 520 | impl fmt::Display for EditAccountStatus { 521 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 522 | write!(f, "Edit Account Status") 523 | } 524 | } 525 | 526 | impl Action for EditAccountStatus 527 | where 528 | ATV: ActionTypeValueEnum, 529 | { 530 | fn date(&self) -> NaiveDate { 531 | self.date 532 | } 533 | 534 | fn perform(&self, program_state: &mut ProgramState) -> Result<(), AccountingError> { 535 | let mut account_state = program_state 536 | .get_account_state_mut(&self.account_id) 537 | .unwrap(); 538 | account_state.status = self.newstatus; 539 | Ok(()) 540 | } 541 | } 542 | 543 | impl ActionTypeFor for EditAccountStatus { 544 | fn action_type(&self) -> ActionType { 545 | ActionType::EditAccountStatus 546 | } 547 | } 548 | 549 | /// A type of [Action](Action) to check and assert the balance of a 550 | /// given [Account](crate::Account) in its [AccountStatus](AccountStatus) at 551 | /// the beginning of the given date. 552 | /// 553 | /// When running its [perform()](Action::perform()) method, if this 554 | /// assertion fails, a [FailedBalanceAssertion](FailedBalanceAssertion) 555 | /// will be recorded in the [ProgramState](ProgramState). 556 | #[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))] 557 | #[derive(Debug, Clone, PartialEq)] 558 | pub struct BalanceAssertion { 559 | account_id: AccountID, 560 | date: NaiveDate, 561 | expected_balance: Commodity, 562 | } 563 | 564 | impl BalanceAssertion { 565 | /// Create a new [BalanceAssertion](BalanceAssertion). The balance 566 | /// will be considered at the beginning of the provided `date`. 567 | pub fn new( 568 | account_id: AccountID, 569 | date: NaiveDate, 570 | expected_balance: Commodity, 571 | ) -> BalanceAssertion { 572 | BalanceAssertion { 573 | account_id, 574 | date, 575 | expected_balance, 576 | } 577 | } 578 | } 579 | 580 | impl fmt::Display for BalanceAssertion { 581 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 582 | write!(f, "Assert Account Balance") 583 | } 584 | } 585 | 586 | /// Records the failure of a [BalanceAssertion](BalanceAssertion) when 587 | /// it is evaluated using its implementation of the 588 | /// [Action::perform()](Action::perform()) method. 589 | #[derive(Debug, Clone)] 590 | pub struct FailedBalanceAssertion { 591 | pub assertion: BalanceAssertion, 592 | pub actual_balance: Commodity, 593 | } 594 | 595 | impl FailedBalanceAssertion { 596 | /// Create a new [FailedBalanceAssertion](FailedBalanceAssertion). 597 | pub fn new(assertion: BalanceAssertion, actual_balance: Commodity) -> FailedBalanceAssertion { 598 | FailedBalanceAssertion { 599 | assertion, 600 | actual_balance, 601 | } 602 | } 603 | } 604 | 605 | impl fmt::Display for FailedBalanceAssertion { 606 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 607 | write!(f, "Failed Account Balance Assertion") 608 | } 609 | } 610 | 611 | // When running this action's `perform()` method implementation, if 612 | // this assertion fails, a [FailedBalanceAssertion](FailedBalanceAssertion) 613 | // will be recorded in the [ProgramState](ProgramState). 614 | impl Action for BalanceAssertion 615 | where 616 | ATV: ActionTypeValueEnum, 617 | { 618 | fn date(&self) -> NaiveDate { 619 | self.date 620 | } 621 | 622 | fn perform(&self, program_state: &mut ProgramState) -> Result<(), AccountingError> { 623 | let failed_assertion = match program_state.get_account_state(&self.account_id) { 624 | Some(state) => { 625 | if !state 626 | .amount 627 | .eq_approx(self.expected_balance, Commodity::default_epsilon()) 628 | { 629 | Some(FailedBalanceAssertion::new(self.clone(), state.amount)) 630 | } else { 631 | None 632 | } 633 | } 634 | None => { 635 | return Err(AccountingError::MissingAccountState(self.account_id)); 636 | } 637 | }; 638 | 639 | if let Some(failed_assertion) = failed_assertion { 640 | program_state.record_failed_balance_assertion(failed_assertion) 641 | } 642 | 643 | Ok(()) 644 | } 645 | } 646 | 647 | impl ActionTypeFor for BalanceAssertion { 648 | fn action_type(&self) -> ActionType { 649 | ActionType::BalanceAssertion 650 | } 651 | } 652 | 653 | #[cfg(test)] 654 | mod tests { 655 | use super::ActionType; 656 | use crate::{ 657 | Account, AccountStatus, AccountingError, ActionTypeValue, BalanceAssertion, Program, 658 | ProgramState, Transaction, 659 | }; 660 | use chrono::NaiveDate; 661 | use commodity::{Commodity, CommodityType}; 662 | use rust_decimal::Decimal; 663 | use std::{collections::HashSet, rc::Rc}; 664 | 665 | #[test] 666 | fn action_type_order() { 667 | let mut tested_types: HashSet = HashSet::new(); 668 | 669 | let mut action_types_unordered: Vec = vec![ 670 | ActionType::Transaction, 671 | ActionType::EditAccountStatus, 672 | ActionType::BalanceAssertion, 673 | ActionType::EditAccountStatus, 674 | ActionType::Transaction, 675 | ActionType::BalanceAssertion, 676 | ]; 677 | 678 | let num_action_types = ActionType::iterator().count(); 679 | 680 | action_types_unordered.iter().for_each(|action_type| { 681 | tested_types.insert(action_type.clone()); 682 | }); 683 | 684 | assert_eq!(num_action_types, tested_types.len()); 685 | 686 | action_types_unordered.sort(); 687 | 688 | let action_types_ordered: Vec = vec![ 689 | ActionType::EditAccountStatus, 690 | ActionType::EditAccountStatus, 691 | ActionType::BalanceAssertion, 692 | ActionType::BalanceAssertion, 693 | ActionType::Transaction, 694 | ActionType::Transaction, 695 | ]; 696 | 697 | assert_eq!(action_types_ordered, action_types_unordered); 698 | } 699 | 700 | #[test] 701 | fn balance_assertion() { 702 | let aud = Rc::from(CommodityType::from_str("AUD", "Australian Dollar").unwrap()); 703 | let account1 = Rc::from(Account::new_with_id(Some("Account 1"), aud.id, None)); 704 | let account2 = Rc::from(Account::new_with_id(Some("Account 2"), aud.id, None)); 705 | 706 | let date_1 = NaiveDate::from_ymd(2020, 01, 01); 707 | let date_2 = NaiveDate::from_ymd(2020, 01, 02); 708 | let actions: Vec> = vec![ 709 | Rc::new( 710 | Transaction::new_simple::( 711 | None, 712 | date_1.clone(), 713 | account1.id, 714 | account2.id, 715 | Commodity::new(Decimal::new(100, 2), &*aud), 716 | None, 717 | ) 718 | .into(), 719 | ), 720 | // This assertion is expected to fail because it occurs at the start 721 | // of the day (before the transaction). 722 | Rc::new( 723 | BalanceAssertion::new( 724 | account2.id, 725 | date_1.clone(), 726 | Commodity::new(Decimal::new(100, 2), &*aud), 727 | ) 728 | .into(), 729 | ), 730 | // This assertion is expected to pass because it occurs at the end 731 | // of the day (after the transaction). 732 | Rc::new( 733 | BalanceAssertion::new( 734 | account2.id, 735 | date_2.clone(), 736 | Commodity::new(Decimal::new(100, 2), &*aud), 737 | ) 738 | .into(), 739 | ), 740 | ]; 741 | 742 | let program = Program::new(actions); 743 | 744 | let accounts = vec![account1, account2]; 745 | let mut program_state = ProgramState::new(&accounts, AccountStatus::Open); 746 | match program_state.execute_program(&program) { 747 | Err(AccountingError::BalanceAssertionFailed(failure)) => { 748 | assert_eq!( 749 | Commodity::new(Decimal::new(0, 2), &*aud), 750 | failure.actual_balance 751 | ); 752 | assert_eq!(date_1, failure.assertion.date); 753 | } 754 | _ => panic!("Expected an AccountingError:BalanceAssertionFailed"), 755 | } 756 | 757 | assert_eq!(1, program_state.failed_balance_assertions.len()); 758 | } 759 | } 760 | 761 | #[cfg(feature = "serde-support")] 762 | #[cfg(test)] 763 | mod serde_tests { 764 | use super::{BalanceAssertion, EditAccountStatus, Transaction}; 765 | use crate::{AccountID, AccountStatus}; 766 | use chrono::NaiveDate; 767 | use commodity::Commodity; 768 | use std::str::FromStr; 769 | 770 | #[test] 771 | fn edit_account_status_serde() { 772 | use serde_json; 773 | 774 | let json = r#"{ 775 | "account_id": "TestAccount", 776 | "newstatus": "Open", 777 | "date": "2020-05-10" 778 | }"#; 779 | let action: EditAccountStatus = serde_json::from_str(json).unwrap(); 780 | 781 | let reference_action = EditAccountStatus::new( 782 | AccountID::from("TestAccount").unwrap(), 783 | AccountStatus::Open, 784 | NaiveDate::from_ymd(2020, 05, 10), 785 | ); 786 | 787 | assert_eq!(action, reference_action); 788 | 789 | insta::assert_json_snapshot!(action); 790 | } 791 | 792 | #[test] 793 | fn balance_assertion_serde() { 794 | use serde_json; 795 | 796 | let json = r#"{ 797 | "account_id": "TestAccount", 798 | "date": "2020-05-10", 799 | "expected_balance": { 800 | "value": "1.0", 801 | "type_id": "AUD" 802 | } 803 | }"#; 804 | let action: BalanceAssertion = serde_json::from_str(json).unwrap(); 805 | 806 | let reference_action = BalanceAssertion::new( 807 | AccountID::from("TestAccount").unwrap(), 808 | NaiveDate::from_ymd(2020, 05, 10), 809 | Commodity::from_str("1.0 AUD").unwrap(), 810 | ); 811 | 812 | assert_eq!(action, reference_action); 813 | 814 | insta::assert_json_snapshot!(action); 815 | } 816 | 817 | #[cfg(feature = "serde-support")] 818 | #[test] 819 | fn transaction_serde() { 820 | use serde_json; 821 | 822 | let json = r#"{ 823 | "description": "TestTransaction", 824 | "date": "2020-05-10", 825 | "elements": [ 826 | { 827 | "account_id": "TestAccount1", 828 | "amount": { 829 | "value": "-1.0", 830 | "type_id": "AUD" 831 | } 832 | }, 833 | { 834 | "account_id": "TestAccount2" 835 | } 836 | ] 837 | }"#; 838 | let action: Transaction = serde_json::from_str(json).unwrap(); 839 | 840 | let reference_action = Transaction::new_simple( 841 | Some("TestTransaction"), 842 | NaiveDate::from_ymd(2020, 05, 10), 843 | AccountID::from("TestAccount1").unwrap(), 844 | AccountID::from("TestAccount2").unwrap(), 845 | Commodity::from_str("1.0 AUD").unwrap(), 846 | None, 847 | ); 848 | 849 | assert_eq!(action, reference_action); 850 | 851 | insta::assert_json_snapshot!(action); 852 | } 853 | } 854 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use super::{AccountID, AccountStatus, FailedBalanceAssertion, Transaction}; 2 | use commodity::exchange_rate::ExchangeRateError; 3 | use commodity::{Commodity, CommodityError, CommodityTypeID}; 4 | use thiserror::Error; 5 | 6 | /// An error associated with functionality in the [accounting](./index.html) module. 7 | /// 8 | /// TODO: add context for the error for where it occurred within the [Program](super::Program) 9 | #[derive(Error, Debug)] 10 | pub enum AccountingError { 11 | #[error("error relating to a commodity")] 12 | Commodity(#[from] CommodityError), 13 | #[error("error relating to exchange rates")] 14 | ExchangeRate(#[from] ExchangeRateError), 15 | #[error("invalid account status ({:?}) for account {}", .status, .account_id)] 16 | InvalidAccountStatus { 17 | account_id: AccountID, 18 | status: AccountStatus, 19 | }, 20 | #[error("error parsing a date from string")] 21 | DateParseError(#[from] chrono::ParseError), 22 | #[error("invalid transaction {0:?} because {1}")] 23 | InvalidTransaction(Transaction, String), 24 | #[error("failed checksum, the sum of account values in the common commodity type ({0}) does not equal zero")] 25 | FailedCheckSum(Commodity), 26 | #[error("no exchange rate supplied, unable to convert commodity {0} to type {1}")] 27 | NoExchangeRateSupplied(Commodity, CommodityTypeID), 28 | #[error("the account state with the id {0} was requested but cannot be found")] 29 | MissingAccountState(AccountID), 30 | #[error("the balance assertion failed {0}")] 31 | BalanceAssertionFailed(FailedBalanceAssertion), 32 | } 33 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A double entry accounting system/library. 2 | //! 3 | //! # Optional Features 4 | //! 5 | //! The doublecount package has the following optional cargo features: 6 | //! 7 | //! + `serde-support` 8 | //! + Disabled by default 9 | //! + Enables support for serialization/de-serialization via `serde` 10 | //! 11 | //! # Usage 12 | //! 13 | //! ``` 14 | //! use doublecount::{ 15 | //! AccountStatus, EditAccountStatus, Account, Program, Action, 16 | //! ProgramState, Transaction, TransactionElement, BalanceAssertion, 17 | //! ActionTypeValue, 18 | //! }; 19 | //! use commodity::{CommodityType, Commodity}; 20 | //! use chrono::NaiveDate; 21 | //! use std::rc::Rc; 22 | //! use std::str::FromStr; 23 | //! 24 | //! // create a commodity from a currency's iso4317 alphanumeric code 25 | //! let aud = Rc::from(CommodityType::from_str("AUD", "Australian Dollar").unwrap()); 26 | //! 27 | //! // Create a couple of accounts 28 | //! let account1 = Rc::from(Account::new_with_id(Some("Account 1"), aud.id, None)); 29 | //! let account2 = Rc::from(Account::new_with_id(Some("Account 2"), aud.id, None)); 30 | //! 31 | //! // create a new program state, with accounts starting Closed 32 | //! let mut program_state = ProgramState::new( 33 | //! &vec![account1.clone(), account2.clone()], 34 | //! AccountStatus::Closed 35 | //! ); 36 | //! 37 | //! // open account1 38 | //! let open_account1 = EditAccountStatus::new( 39 | //! account1.id, 40 | //! AccountStatus::Open, 41 | //! NaiveDate::from_str("2020-01-01").unwrap(), 42 | //! ); 43 | //! 44 | //! // open account2 45 | //! let open_account2 = EditAccountStatus::new( 46 | //! account2.id, 47 | //! AccountStatus::Open, 48 | //! NaiveDate::from_str("2020-01-01").unwrap(), 49 | //! ); 50 | //! 51 | //! // create a transaction to transfer some commodity 52 | //! // from account1 to account2. 53 | //! let transaction1 = Transaction::new( 54 | //! Some(String::from("Transaction 1")), 55 | //! NaiveDate::from_str("2020-01-02").unwrap(), 56 | //! vec![ 57 | //! TransactionElement::new( 58 | //! account1.id, 59 | //! Some(Commodity::from_str("-2.52 AUD").unwrap()), 60 | //! None, 61 | //! ), 62 | //! TransactionElement::new( 63 | //! account2.id, 64 | //! Some(Commodity::from_str("2.52 AUD").unwrap()), 65 | //! None, 66 | //! ), 67 | //! ], 68 | //! ); 69 | //! 70 | //! // create a balance assertion (that will cause the program to return an error 71 | //! // if it fails), to check that the balance of account1 matches the expected 72 | //! // value of -1.52 AUD at the start of the date of 2020-01-03 73 | //! let balance_assertion1 = BalanceAssertion::new( 74 | //! account1.id, 75 | //! NaiveDate::from_str("2020-01-03").unwrap(), 76 | //! Commodity::from_str("-2.52 AUD").unwrap() 77 | //! ); 78 | //! 79 | //! // create another transaction to transfer commodity from 80 | //! // account2 to account1, using the simpler syntax. 81 | //! let transaction2 = Transaction::new_simple( 82 | //! Some("Transaction 2"), 83 | //! NaiveDate::from_str("2020-01-03").unwrap(), 84 | //! account2.id, 85 | //! account1.id, 86 | //! Commodity::from_str("1.0 AUD").unwrap(), 87 | //! None, 88 | //! ); 89 | //! 90 | //! let balance_assertion2 = BalanceAssertion::new( 91 | //! account1.id, 92 | //! NaiveDate::from_str("2020-01-04").unwrap(), 93 | //! Commodity::from_str("-1.52 AUD").unwrap() 94 | //! ); 95 | //! 96 | //! let balance_assertion3 = BalanceAssertion::new( 97 | //! account2.id, 98 | //! NaiveDate::from_str("2020-01-04").unwrap(), 99 | //! Commodity::from_str("1.52 AUD").unwrap() 100 | //! ); 101 | //! 102 | //! let actions: Vec> = vec![ 103 | //! Rc::new(open_account1.into()), 104 | //! Rc::new(open_account2.into()), 105 | //! Rc::new(transaction1.into()), 106 | //! Rc::new(balance_assertion1.into()), 107 | //! Rc::new(transaction2.into()), 108 | //! Rc::new(balance_assertion2.into()), 109 | //! Rc::new(balance_assertion3.into()), 110 | //! ]; 111 | //! 112 | //! // create a program from the actions 113 | //! let program = Program::new(actions); 114 | //! 115 | //! // run the program 116 | //! program_state.execute_program(&program).unwrap(); 117 | //! ``` 118 | 119 | extern crate arrayvec; 120 | extern crate chrono; 121 | extern crate commodity; 122 | extern crate nanoid; 123 | extern crate rust_decimal; 124 | extern crate thiserror; 125 | 126 | #[cfg(feature = "serde-support")] 127 | extern crate serde; 128 | 129 | #[cfg(test)] 130 | #[cfg(feature = "serde-support")] 131 | extern crate serde_json; 132 | 133 | mod account; 134 | mod actions; 135 | mod error; 136 | mod program; 137 | 138 | pub use account::*; 139 | pub use actions::*; 140 | pub use error::AccountingError; 141 | pub use program::*; 142 | 143 | #[cfg(doctest)] 144 | #[macro_use] 145 | extern crate doc_comment; 146 | 147 | #[cfg(doctest)] 148 | doctest!("../README.md"); 149 | 150 | #[cfg(test)] 151 | mod tests { 152 | use super::{ 153 | sum_account_states, Account, AccountState, AccountStatus, BalanceAssertion, 154 | EditAccountStatus, Program, ProgramState, Transaction, TransactionElement, 155 | }; 156 | use crate::ActionTypeValue; 157 | use chrono::NaiveDate; 158 | use commodity::{Commodity, CommodityType, CommodityTypeID}; 159 | use std::rc::Rc; 160 | use std::str::FromStr; 161 | 162 | #[test] 163 | fn execute_program() { 164 | let aud = Rc::from(CommodityType::new( 165 | CommodityTypeID::from_str("AUD").unwrap(), 166 | None, 167 | )); 168 | let account1 = Rc::from(Account::new_with_id(Some("Account 1"), aud.id, None)); 169 | let account2 = Rc::from(Account::new_with_id(Some("Account 2"), aud.id, None)); 170 | 171 | let accounts = vec![account1.clone(), account2.clone()]; 172 | 173 | let mut program_state = ProgramState::new(&accounts, AccountStatus::Closed); 174 | 175 | let open_account1 = EditAccountStatus::new( 176 | account1.id, 177 | AccountStatus::Open, 178 | NaiveDate::from_str("2020-01-01").unwrap(), 179 | ); 180 | 181 | let open_account2 = EditAccountStatus::new( 182 | account2.id, 183 | AccountStatus::Open, 184 | NaiveDate::from_str("2020-01-01").unwrap(), 185 | ); 186 | 187 | let transaction1 = Transaction::new( 188 | Some(String::from("Transaction 1")), 189 | NaiveDate::from_str("2020-01-02").unwrap(), 190 | vec![ 191 | TransactionElement::new( 192 | account1.id, 193 | Some(Commodity::from_str("-2.52 AUD").unwrap()), 194 | None, 195 | ), 196 | TransactionElement::new( 197 | account2.id, 198 | Some(Commodity::from_str("2.52 AUD").unwrap()), 199 | None, 200 | ), 201 | ], 202 | ); 203 | 204 | let transaction2 = Transaction::new( 205 | Some(String::from("Transaction 2")), 206 | NaiveDate::from_str("2020-01-02").unwrap(), 207 | vec![ 208 | TransactionElement::new( 209 | account1.id, 210 | Some(Commodity::from_str("-1.0 AUD").unwrap()), 211 | None, 212 | ), 213 | TransactionElement::new(account2.id, None, None), 214 | ], 215 | ); 216 | 217 | let balance_assertion = BalanceAssertion::new( 218 | account1.id, 219 | NaiveDate::from_str("2020-01-03").unwrap(), 220 | Commodity::from_str("-3.52 AUD").unwrap(), 221 | ); 222 | 223 | let actions: Vec> = vec![ 224 | Rc::new(open_account1.into()), 225 | Rc::new(open_account2.into()), 226 | Rc::new(transaction1.into()), 227 | Rc::new(transaction2.into()), 228 | Rc::new(balance_assertion.into()), 229 | ]; 230 | 231 | let program = Program::new(actions); 232 | 233 | let account1_state_before: AccountState = program_state 234 | .get_account_state(&account1.id) 235 | .unwrap() 236 | .clone(); 237 | 238 | assert_eq!(AccountStatus::Closed, account1_state_before.status); 239 | 240 | program_state.execute_program(&program).unwrap(); 241 | 242 | let account1_state_after: AccountState = program_state 243 | .get_account_state(&account1.id) 244 | .unwrap() 245 | .clone(); 246 | 247 | assert_eq!(AccountStatus::Open, account1_state_after.status); 248 | assert_eq!( 249 | Commodity::from_str("-3.52 AUD").unwrap(), 250 | account1_state_after.amount 251 | ); 252 | 253 | assert_eq!( 254 | Commodity::from_str("0.0 AUD").unwrap(), 255 | sum_account_states( 256 | &program_state.account_states, 257 | CommodityTypeID::from_str("AUD").unwrap(), 258 | None 259 | ) 260 | .unwrap() 261 | ); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/program.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | Account, AccountID, AccountState, AccountStatus, AccountingError, ActionOrder, 3 | FailedBalanceAssertion, 4 | }; 5 | use commodity::exchange_rate::ExchangeRate; 6 | use commodity::{Commodity, CommodityTypeID}; 7 | use std::collections::HashMap; 8 | use std::marker::PhantomData; 9 | use std::rc::Rc; 10 | 11 | use crate::{ActionType, ActionTypeFor, ActionTypeValue, ActionTypeValueEnum}; 12 | #[cfg(feature = "serde-support")] 13 | use serde::{de, ser::SerializeSeq, Deserialize, Deserializer, Serialize, Serializer}; 14 | 15 | /// A collection of [Action](Action)s to be executed in order to 16 | /// mutate some [ProgramState](ProgramState). 17 | #[derive(Debug, Clone, PartialEq)] 18 | pub struct Program { 19 | pub actions: Vec>, 20 | action_type: PhantomData, 21 | } 22 | 23 | impl Program 24 | where 25 | AT: Ord, 26 | ATV: ActionTypeValueEnum + ActionTypeFor, 27 | { 28 | /// Create a new [Program](Program). 29 | /// 30 | /// The provided `actions` will be sorted using [ActionOrder](ActionOrder). 31 | pub fn new(actions: Vec>) -> Program { 32 | let mut sorted_actions: Vec> = actions; 33 | sorted_actions.sort_by_key(|a| ActionOrder::new(a.clone())); 34 | Program { 35 | actions: sorted_actions, 36 | action_type: PhantomData::default(), 37 | } 38 | } 39 | 40 | /// The number of actions in this program. 41 | pub fn len(&self) -> usize { 42 | self.actions.len() 43 | } 44 | 45 | /// Returns true if there are no actions in this progam. 46 | pub fn is_empty(&self) -> bool { 47 | self.actions.is_empty() 48 | } 49 | } 50 | 51 | #[cfg(feature = "serde-support")] 52 | struct ProgramVisitor { 53 | action_type: PhantomData, 54 | action_type_value: PhantomData, 55 | } 56 | 57 | #[cfg(feature = "serde-support")] 58 | impl ProgramVisitor { 59 | pub fn new() -> Self { 60 | Self { 61 | action_type: PhantomData::default(), 62 | action_type_value: PhantomData::default(), 63 | } 64 | } 65 | } 66 | 67 | #[cfg(feature = "serde-support")] 68 | impl<'de, AT, ATV> de::Visitor<'de> for ProgramVisitor 69 | where 70 | AT: Ord, 71 | ATV: Deserialize<'de> + ActionTypeValueEnum + ActionTypeFor, 72 | { 73 | type Value = Program; 74 | 75 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 76 | formatter.write_str("Program comprising of a vector of Actions") 77 | } 78 | 79 | fn visit_seq(self, mut seq: S) -> Result, S::Error> 80 | where 81 | S: de::SeqAccess<'de>, 82 | { 83 | let mut actions: Vec> = match seq.size_hint() { 84 | Some(size_hint) => Vec::with_capacity(size_hint), 85 | None => Vec::new(), 86 | }; 87 | 88 | while let Some(action) = seq.next_element::()? { 89 | actions.push(Rc::new(action)); 90 | } 91 | 92 | Ok(Program::new(actions)) 93 | } 94 | } 95 | 96 | #[cfg(feature = "serde-support")] 97 | impl<'de, AT, ATV> Deserialize<'de> for Program 98 | where 99 | AT: Ord, 100 | ATV: Deserialize<'de> + ActionTypeValueEnum + ActionTypeFor, 101 | { 102 | fn deserialize(deserializer: D) -> std::result::Result, D::Error> 103 | where 104 | D: Deserializer<'de>, 105 | { 106 | deserializer.deserialize_seq(ProgramVisitor::::new()) 107 | } 108 | } 109 | 110 | #[cfg(feature = "serde-support")] 111 | impl Serialize for Program 112 | where 113 | ATV: Serialize, 114 | { 115 | fn serialize(&self, serializer: S) -> Result 116 | where 117 | S: Serializer, 118 | { 119 | let mut seq = serializer.serialize_seq(Some(self.actions.len()))?; 120 | for e in &self.actions { 121 | seq.serialize_element(&**e)?; 122 | } 123 | seq.end() 124 | } 125 | } 126 | 127 | /// The state of a [Program](Program) being executed. 128 | pub struct ProgramState { 129 | /// list of states associated with accounts (can only grow) 130 | pub account_states: HashMap, 131 | 132 | /// list of failed assertions, and associated failed balance 133 | pub failed_balance_assertions: Vec, 134 | 135 | /// the index of the currently executing action 136 | current_action_index: usize, 137 | 138 | action_type: PhantomData, 139 | action_type_value: PhantomData, 140 | } 141 | 142 | /// Sum the values in all the accounts into a single 143 | /// [Commodity](Commodity), and use the supplied exchange rate if 144 | /// required to convert a type of commodity in an account to the 145 | /// [CommidityType](commodity::CommodityType) associated with the 146 | /// id `sum_commodity_type_id`. 147 | pub fn sum_account_states( 148 | account_states: &HashMap, 149 | sum_commodity_type_id: CommodityTypeID, 150 | exchange_rate: Option<&ExchangeRate>, 151 | ) -> Result { 152 | let mut sum = Commodity::zero(sum_commodity_type_id); 153 | 154 | for account_state in account_states.values() { 155 | let account_amount = if account_state.amount.type_id != sum_commodity_type_id { 156 | match exchange_rate { 157 | Some(rate) => rate.convert(account_state.amount, sum_commodity_type_id)?, 158 | None => { 159 | return Err(AccountingError::NoExchangeRateSupplied( 160 | account_state.amount, 161 | sum_commodity_type_id, 162 | )) 163 | } 164 | } 165 | } else { 166 | account_state.amount 167 | }; 168 | 169 | sum = sum.add(&account_amount)?; 170 | } 171 | 172 | Ok(sum) 173 | } 174 | 175 | impl ProgramState 176 | where 177 | ATV: ActionTypeValueEnum, 178 | { 179 | /// Create a new [ProgramState](ProgramState). 180 | pub fn new(accounts: &[Rc], account_status: AccountStatus) -> ProgramState { 181 | let mut account_states = HashMap::new(); 182 | 183 | for account in accounts { 184 | account_states.insert( 185 | account.id, 186 | AccountState::new( 187 | account.clone(), 188 | Commodity::zero(account.commodity_type_id), 189 | account_status, 190 | ), 191 | ); 192 | } 193 | 194 | ProgramState { 195 | account_states, 196 | failed_balance_assertions: Vec::new(), 197 | current_action_index: 0, 198 | action_type: PhantomData::default(), 199 | action_type_value: PhantomData::default(), 200 | } 201 | } 202 | 203 | /// Execute a given [Program](Program) to mutate this state. 204 | pub fn execute_program(&mut self, program: &Program) -> Result<(), AccountingError> { 205 | for (index, action) in program.actions.iter().enumerate() { 206 | action.as_action().perform(self)?; 207 | self.current_action_index = index; 208 | } 209 | 210 | // TODO: change this to return a list of failed assertions in the error 211 | if let Some(failed_assertion) = self.failed_balance_assertions.get(0) { 212 | return Err(AccountingError::BalanceAssertionFailed( 213 | failed_assertion.clone(), 214 | )); 215 | } 216 | 217 | Ok(()) 218 | } 219 | 220 | /// Get the reference to an [Account](Account) using it's [AccountID](AccountID). 221 | pub fn get_account(&self, account_id: &AccountID) -> Option<&Account> { 222 | self.get_account_state(account_id) 223 | .map(|state| state.account.as_ref()) 224 | } 225 | 226 | /// Get a reference to the `AccountState` associated with a given `Account`. 227 | pub fn get_account_state(&self, account_id: &AccountID) -> Option<&AccountState> { 228 | self.account_states.get(account_id) 229 | } 230 | 231 | /// Get a mutable reference to the `AccountState` associated with the given `Account`. 232 | pub fn get_account_state_mut(&mut self, account_id: &AccountID) -> Option<&mut AccountState> { 233 | self.account_states.get_mut(account_id) 234 | } 235 | 236 | /// Record a failed [BalanceAssertion](super::BalanceAssertion) 237 | /// using a [FailedBalanceAssertion](FailedBalanceAssertion). 238 | pub fn record_failed_balance_assertion( 239 | &mut self, 240 | failed_balance_assertion: FailedBalanceAssertion, 241 | ) { 242 | self.failed_balance_assertions 243 | .push(failed_balance_assertion); 244 | } 245 | } 246 | 247 | #[cfg(feature = "serde-support")] 248 | #[cfg(test)] 249 | mod tests { 250 | use super::Program; 251 | use crate::{ 252 | Account, AccountID, AccountStatus, ActionTypeValue, BalanceAssertion, EditAccountStatus, 253 | Transaction, TransactionElement, 254 | }; 255 | use chrono::NaiveDate; 256 | use commodity::{Commodity, CommodityType, CommodityTypeID}; 257 | use std::{rc::Rc, str::FromStr}; 258 | 259 | #[test] 260 | fn program_serde() { 261 | let json = r#" 262 | [ 263 | { 264 | "type": "EditAccountStatus", 265 | "account_id": "TestAccount1", 266 | "newstatus": "Open", 267 | "date": "2020-01-01" 268 | }, 269 | { 270 | "type": "EditAccountStatus", 271 | "account_id": "TestAccount2", 272 | "newstatus": "Open", 273 | "date": "2020-01-01" 274 | }, 275 | { 276 | "type": "Transaction", 277 | "description": "Test Transaction", 278 | "date": "2020-01-02", 279 | "elements": [ 280 | { 281 | "account_id": "TestAccount1", 282 | "amount": { 283 | "value": "-2.52", 284 | "type_id": "AUD" 285 | } 286 | }, 287 | { 288 | "account_id": "TestAccount2", 289 | "amount": { 290 | "value": "2.52", 291 | "type_id": "AUD" 292 | } 293 | } 294 | ] 295 | }, 296 | { 297 | "type": "BalanceAssertion", 298 | "account_id": "TestAccount1", 299 | "date": "2020-01-03", 300 | "expected_balance": { 301 | "value": "-3.52", 302 | "type_id": "AUD" 303 | } 304 | } 305 | ]"#; 306 | let program: Program = serde_json::from_str(json).unwrap(); 307 | 308 | let aud = Rc::from(CommodityType::new( 309 | CommodityTypeID::from_str("AUD").unwrap(), 310 | None, 311 | )); 312 | 313 | let account1 = Rc::from(Account::new( 314 | AccountID::from("TestAccount1").unwrap(), 315 | Some("Test Account 1"), 316 | aud.id, 317 | None, 318 | )); 319 | let account2 = Rc::from(Account::new( 320 | AccountID::from("TestAccount2").unwrap(), 321 | Some("Test Account 2"), 322 | aud.id, 323 | None, 324 | )); 325 | 326 | let open_account1 = EditAccountStatus::new( 327 | account1.id, 328 | AccountStatus::Open, 329 | NaiveDate::from_str("2020-01-01").unwrap(), 330 | ); 331 | 332 | let open_account2 = EditAccountStatus::new( 333 | account2.id, 334 | AccountStatus::Open, 335 | NaiveDate::from_str("2020-01-01").unwrap(), 336 | ); 337 | 338 | let transaction = Transaction::new( 339 | Some(String::from("Test Transaction")), 340 | NaiveDate::from_str("2020-01-02").unwrap(), 341 | vec![ 342 | TransactionElement::new( 343 | account1.id, 344 | Some(Commodity::from_str("-2.52 AUD").unwrap()), 345 | None, 346 | ), 347 | TransactionElement::new( 348 | account2.id, 349 | Some(Commodity::from_str("2.52 AUD").unwrap()), 350 | None, 351 | ), 352 | ], 353 | ); 354 | 355 | let balance_assertion = BalanceAssertion::new( 356 | account1.id, 357 | NaiveDate::from_str("2020-01-03").unwrap(), 358 | Commodity::from_str("-3.52 AUD").unwrap(), 359 | ); 360 | 361 | let actions: Vec> = vec![ 362 | Rc::new(open_account1.into()), 363 | Rc::new(open_account2.into()), 364 | Rc::new(transaction.into()), 365 | Rc::new(balance_assertion.into()), 366 | ]; 367 | 368 | let reference_program = Program::new(actions); 369 | 370 | assert_eq!(reference_program, program); 371 | 372 | insta::assert_json_snapshot!(program); 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /src/snapshots/doublecount__account__serde_tests__account_serde.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/account.rs 3 | expression: account 4 | --- 5 | { 6 | "id": "ABCDEFGHIJKLMNOPQRST", 7 | "name": "Test Account", 8 | "commodity_type_id": "USD", 9 | "category": "Expense" 10 | } 11 | -------------------------------------------------------------------------------- /src/snapshots/doublecount__actions__serde_tests__balance_assertion_serde.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/actions.rs 3 | expression: action 4 | --- 5 | { 6 | "account_id": "TestAccount", 7 | "date": "2020-05-10", 8 | "expected_balance": { 9 | "value": "1.0", 10 | "type_id": "AUD" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/snapshots/doublecount__actions__serde_tests__edit_account_status_serde.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/actions.rs 3 | expression: action 4 | --- 5 | { 6 | "account_id": "TestAccount", 7 | "newstatus": "Open", 8 | "date": "2020-05-10" 9 | } 10 | -------------------------------------------------------------------------------- /src/snapshots/doublecount__actions__serde_tests__transaction_serde.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/actions.rs 3 | expression: action 4 | --- 5 | { 6 | "description": "TestTransaction", 7 | "date": "2020-05-10", 8 | "elements": [ 9 | { 10 | "account_id": "TestAccount1", 11 | "amount": { 12 | "value": "-1.0", 13 | "type_id": "AUD" 14 | }, 15 | "exchange_rate": null 16 | }, 17 | { 18 | "account_id": "TestAccount2", 19 | "amount": null, 20 | "exchange_rate": null 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/snapshots/doublecount__program__tests__program_serde.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/program.rs 3 | expression: program 4 | --- 5 | [ 6 | { 7 | "type": "EditAccountStatus", 8 | "account_id": "TestAccount1", 9 | "newstatus": "Open", 10 | "date": "2020-01-01" 11 | }, 12 | { 13 | "type": "EditAccountStatus", 14 | "account_id": "TestAccount2", 15 | "newstatus": "Open", 16 | "date": "2020-01-01" 17 | }, 18 | { 19 | "type": "Transaction", 20 | "description": "Test Transaction", 21 | "date": "2020-01-02", 22 | "elements": [ 23 | { 24 | "account_id": "TestAccount1", 25 | "amount": { 26 | "value": "-2.52", 27 | "type_id": "AUD" 28 | }, 29 | "exchange_rate": null 30 | }, 31 | { 32 | "account_id": "TestAccount2", 33 | "amount": { 34 | "value": "2.52", 35 | "type_id": "AUD" 36 | }, 37 | "exchange_rate": null 38 | } 39 | ] 40 | }, 41 | { 42 | "type": "BalanceAssertion", 43 | "account_id": "TestAccount1", 44 | "date": "2020-01-03", 45 | "expected_balance": { 46 | "value": "-3.52", 47 | "type_id": "AUD" 48 | } 49 | } 50 | ] 51 | --------------------------------------------------------------------------------