initial messy commit
This commit is contained in:
commit
423bbd8eb4
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
/Cargo.lock
|
||||||
|
/data/
|
||||||
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "accounting-rust"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = "0.4.39"
|
||||||
|
nom = "7.1.3"
|
||||||
|
nom_locate = "4.2.0"
|
||||||
|
rand = "0.8.5"
|
||||||
|
ratatui = "0.29.0"
|
||||||
|
rust_decimal = "1.36.0"
|
||||||
|
rust_decimal_macros = "1.36.0"
|
||||||
526
notes.md
Normal file
526
notes.md
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
## Transactions
|
||||||
|
|
||||||
|
```
|
||||||
|
; Simplest Transaction
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 200 USD
|
||||||
|
Account2 -200 USD
|
||||||
|
|
||||||
|
; With Payee
|
||||||
|
2023-01-01 * Payee
|
||||||
|
Account1 200 USD
|
||||||
|
Account2 -200 USD
|
||||||
|
|
||||||
|
; With Payee and Narration
|
||||||
|
2023-01-01 * Payee | Narration
|
||||||
|
Account1 200 USD
|
||||||
|
Account2 -200 USD
|
||||||
|
|
||||||
|
; With Narration only
|
||||||
|
2023-01-01 * | Narration
|
||||||
|
Account1 200 USD
|
||||||
|
Account2 -200 USD
|
||||||
|
|
||||||
|
; Incomplete Transaction
|
||||||
|
2000-01-01 !
|
||||||
|
Account1 200 USD
|
||||||
|
Account2 -200 USD
|
||||||
|
|
||||||
|
; Partial incomplete transaction
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 200 USD
|
||||||
|
! Account2 -200 USD
|
||||||
|
```
|
||||||
|
|
||||||
|
# Conversion/inventories
|
||||||
|
|
||||||
|
```
|
||||||
|
; TODO: how to define currency conversion does not have cost basis
|
||||||
|
|
||||||
|
; Currency conversion per unit
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 10 USD @ 1.2 EUR
|
||||||
|
Account2 -12 EUR
|
||||||
|
|
||||||
|
; Currency conversion total cost
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 10 USD @@ 12 EUR
|
||||||
|
Account2 -12 EUR
|
||||||
|
|
||||||
|
; Inventory with cost basis per unit
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 10 AAPL @ 100 USD
|
||||||
|
Account2 -1000 USD
|
||||||
|
|
||||||
|
; Inventory with total cost basis
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 10 AAPL @@ 1000 USD
|
||||||
|
Account2 -1000 USD
|
||||||
|
|
||||||
|
; Inventory with explicit unit cost basis + other properties
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 10 AAPL {100 USD, 2000-01-02, "lot 1"} @ 100 USD
|
||||||
|
Account2 -1000 USD
|
||||||
|
|
||||||
|
; Inventory with explicit total cost basis?
|
||||||
|
2000-01-01 *
|
||||||
|
; TODO better syntax?
|
||||||
|
Account1 10 AAPL {{1000 USD, 2000-01-02, "lot 1"}} @@ 1000 USD
|
||||||
|
Account2 -1000 USD
|
||||||
|
|
||||||
|
; Inventory specifying cost basis and current price
|
||||||
|
2000-01-01 *
|
||||||
|
; Current market value conversion == $1100
|
||||||
|
Account1 10 AAPL {100 USD} @ 110 USD
|
||||||
|
Account2 -1000 USD
|
||||||
|
|
||||||
|
; Sell with price known
|
||||||
|
2000-01-01 *
|
||||||
|
; Assuming lot existed of 10 AAPL @ 100 USD
|
||||||
|
; Current price needs to match up with non-Income accounts
|
||||||
|
; (-10 * $200 + $2000) == 0
|
||||||
|
Account1 -10 AAPL @ 200 USD
|
||||||
|
Account2 2000 USD
|
||||||
|
Income:Gains -1000 USD
|
||||||
|
|
||||||
|
; Sell with gains known
|
||||||
|
2000-01-01 *
|
||||||
|
; Assuming lot existed of 10 AAPL @ 100 USD
|
||||||
|
; Compute current price of -10 * x + 2000 USD == 0 -> x = 200 USD?
|
||||||
|
Account1 -10 AAPL
|
||||||
|
Account2 2000 USD
|
||||||
|
Income:Gains -1000 USD
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
; Sell with specific lot price
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 -10 AAPL {100 USD}
|
||||||
|
...
|
||||||
|
|
||||||
|
; Sell with auto lot strategy
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 -10 AAPL {}
|
||||||
|
...
|
||||||
|
|
||||||
|
; Transaction metadata
|
||||||
|
2000-01-01 *
|
||||||
|
key1: "value"
|
||||||
|
key2: 100
|
||||||
|
key3: 2000-01-01
|
||||||
|
key4: Account1:abc
|
||||||
|
key5: 100 USD
|
||||||
|
...
|
||||||
|
|
||||||
|
; Posting metadata
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 200 USD
|
||||||
|
key1: "value"
|
||||||
|
...
|
||||||
|
|
||||||
|
; Postings with different dates
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 200 USD
|
||||||
|
Account2 -200 USD [2000-01-02]
|
||||||
|
|
||||||
|
; ability to specify time? (ISO 8601-ish)
|
||||||
|
; potentially support time as metadata?
|
||||||
|
; arithmetic in amounts: `( ) * / - +`
|
||||||
|
; should currency/commodity be required?
|
||||||
|
; payee per line?
|
||||||
|
; what if inventory doesn't match up perfectly with rounding?
|
||||||
|
; e.g. 4 @ $1.001 = $4.004 and other account has $4.00
|
||||||
|
; ability to specify using average cost basis
|
||||||
|
; for account? for commodity per account?
|
||||||
|
; when selling check
|
||||||
|
; fixed lot prices? {=100 USD}
|
||||||
|
; note - storing unit value is always more accurate, no need to store total cost?
|
||||||
|
; auto import transactions
|
||||||
|
; be able to approve transactions
|
||||||
|
|
||||||
|
; calculate net worth relative to inflation
|
||||||
|
; some way to change value of a dollar?
|
||||||
|
; explicit handling of stock splits to prevent historical price discontinuity?
|
||||||
|
|
||||||
|
|
||||||
|
; ISO 8601 dates
|
||||||
|
YY ~ Century
|
||||||
|
YYY ~
|
||||||
|
YYYY ~ Year
|
||||||
|
YYYY-MM ~ Month
|
||||||
|
YYYY-MM-DD ~ Day
|
||||||
|
YYYY-Www ~ Week
|
||||||
|
YYYY-Www-D ~ Day of week
|
||||||
|
YYYY-DDD ~ Ordinal day?
|
||||||
|
|
||||||
|
; how to represent intervals for projections?
|
||||||
|
xxxx ~ Every year
|
||||||
|
xxxx-MM-DD ~ Every year on MM-DD
|
||||||
|
xxxx-xx-xx ~ Every day
|
||||||
|
YYYY-xx-xx ~ Every day in YYYY
|
||||||
|
xxxx-Wxx ~ Every week
|
||||||
|
xxxx-Wxx-1 ~ Every monday
|
||||||
|
xxxx-xx ~ Every month
|
||||||
|
??? ~ Every quarter
|
||||||
|
??? ~ Every half
|
||||||
|
|
||||||
|
R/P1Y ~ Every year?
|
||||||
|
R/YYYY/P1Y ~ Every year starting with YYYY
|
||||||
|
R/YYYY-MM-DD/P1Y ~ Every year on MM-DD starting with YYYY
|
||||||
|
; TODO: continue this
|
||||||
|
|
||||||
|
R/YYYY-MM-DD/P1Y
|
||||||
|
R/YYYY-MM-DD/P1D
|
||||||
|
R/YYYY-MM-DD/P1D/YYYY-MM-DD
|
||||||
|
$.04
|
||||||
|
; Ability to specify the weekday prior?
|
||||||
|
|
||||||
|
; Buying a house that cost $500,000 with 20% down payment and 5% interest rate for 30 year
|
||||||
|
2000-01-01 * Bank | Take out loan
|
||||||
|
Assets -$100,000
|
||||||
|
Equity:House 1 house @@ $500,000
|
||||||
|
Liabilities:Mortgage -$400,000
|
||||||
|
|
||||||
|
; Amortization schedule:
|
||||||
|
; = P * ((1+r)^n)/((1+r)^n - 1)
|
||||||
|
; P = principal loan amounts
|
||||||
|
; r = monthly interest rate
|
||||||
|
; n = number of payments over loan lifetime
|
||||||
|
|
||||||
|
; P = $400,000
|
||||||
|
; r = 5%/12 = 0.0041666...
|
||||||
|
; n = 12*30 = 360
|
||||||
|
;
|
||||||
|
; Monthly payment = $2147.29
|
||||||
|
|
||||||
|
2000-01-01 * Bank | Mortgage
|
||||||
|
Assets -$2147.29
|
||||||
|
Expenses:Interest:Mortgage (Liabilities:Mortgage * 0.05/12)
|
||||||
|
Liabilities:Mortgage $2147.29 - (Liabilities:Mortgage * 0.05/12)
|
||||||
|
|
||||||
|
2000-01-01 * Bank | Mortgage
|
||||||
|
Assets -$2147.29
|
||||||
|
Expenses:Interest:Mortgage $1666.67
|
||||||
|
Liabilities:Mortgage $480.62
|
||||||
|
|
||||||
|
|
||||||
|
; Automated monthly forecast for mortgage payment
|
||||||
|
~ R360/2000-01-01/P1M * Bank
|
||||||
|
Assets -$2147.29
|
||||||
|
Expenses:Interest:Mortgage (Liabilities:Mortgage * 0.05/12)
|
||||||
|
Liabilities:Mortgage
|
||||||
|
|
||||||
|
; Automated interest from Fidelity - 5% per year
|
||||||
|
~ R/2000-01-01/P1M * Bank
|
||||||
|
Assets:Bank (Assets:Bank-$ * (1.05^(1/12)-1))
|
||||||
|
Income:Interest
|
||||||
|
|
||||||
|
; Automated prices???
|
||||||
|
; TODO
|
||||||
|
~ 2000-01-01/P1Y price AAPL *= 1.05
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
forecast Bank | Mortgage
|
||||||
|
- start: 2000-01-01
|
||||||
|
- repeat: 1 Month
|
||||||
|
- stop: 2001-01-01
|
||||||
|
Assets -$2147.29
|
||||||
|
Expenses:Interest:Mortgage (Liabilities:Mortgage * 0.05/12)
|
||||||
|
Liabilities:Mortgage $2147.29 - (Liabilities:Mortgage * 0.05/12)
|
||||||
|
```
|
||||||
|
|
||||||
|
* Assets
|
||||||
|
* Cash
|
||||||
|
* BofA
|
||||||
|
* Checking
|
||||||
|
* Savings
|
||||||
|
* Coinbase
|
||||||
|
* ESPP
|
||||||
|
* ETrade
|
||||||
|
* Brokerage
|
||||||
|
* RothIRA
|
||||||
|
* Fidelity
|
||||||
|
* 401k
|
||||||
|
* Match
|
||||||
|
* Roth
|
||||||
|
* Trad
|
||||||
|
* Brokerage
|
||||||
|
* Cash Management
|
||||||
|
* House
|
||||||
|
* Liabilities
|
||||||
|
* Credit
|
||||||
|
* BofA
|
||||||
|
* Chase
|
||||||
|
* Mortgage
|
||||||
|
* Expenses
|
||||||
|
* Taxes
|
||||||
|
* Federal
|
||||||
|
* Return
|
||||||
|
* Medicare
|
||||||
|
* SocialSecurity
|
||||||
|
* State
|
||||||
|
* California
|
||||||
|
* SDI
|
||||||
|
* Georgia
|
||||||
|
* Return
|
||||||
|
* Income
|
||||||
|
* Equity
|
||||||
|
* Opening Balance
|
||||||
|
* (CurrencyConversion)
|
||||||
|
* (Trading)
|
||||||
|
* (Transfer)
|
||||||
|
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 10 AAPL @ 100 USD
|
||||||
|
Account2 -1000 USD
|
||||||
|
|
||||||
|
2001-01-01 price AAPL 150 USD
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account1 -10 AAPL @ 200 USD
|
||||||
|
Account2 2000 USD
|
||||||
|
Income:Gains -1000 USD
|
||||||
|
|
||||||
|
```
|
||||||
|
* First:
|
||||||
|
* Account1 ~ 10 AAPL
|
||||||
|
* Account2 ~ -1000 USD
|
||||||
|
* Second:
|
||||||
|
* Account1 ~ 0
|
||||||
|
* Account2 ~ 1000 USD
|
||||||
|
* Income:Gains ~ -1000 USD
|
||||||
|
|
||||||
|
alternate:
|
||||||
|
* First:
|
||||||
|
* Account1 ~ 10 AAPL @ 100 USD
|
||||||
|
* Account2 ~ -1000 USD
|
||||||
|
* Second:
|
||||||
|
* Account1 ~ 0
|
||||||
|
* Account2 ~ 1000 USD
|
||||||
|
* Income:Gains ~ -1000 USD
|
||||||
|
|
||||||
|
with market value:
|
||||||
|
* First:
|
||||||
|
* Account1 ~ 1000 USD
|
||||||
|
* Account2 ~ -1000 USD
|
||||||
|
* (price):
|
||||||
|
* Account1 ~ 1500 USD
|
||||||
|
* Account2 ~ -1000 USD
|
||||||
|
* Equity:Unrealized Capital ~ -500 USD
|
||||||
|
* Second:
|
||||||
|
* Account1 ~ 0
|
||||||
|
* Account2 ~ 1000 USD
|
||||||
|
* Income:Gains ~ -1000 USD
|
||||||
|
* Equity:Unrealized Capital ~ 0
|
||||||
|
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 10 AAPL @ 100 USD
|
||||||
|
Account2 -1000 USD
|
||||||
|
Equity:Trading -10 AAPL
|
||||||
|
Equity:Trading 1000 USD
|
||||||
|
|
||||||
|
2001-01-01 price AAPL 150 USD
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account1 -10 AAPL @ 200 USD
|
||||||
|
Account2 2000 USD
|
||||||
|
Income:Gains -1000 USD
|
||||||
|
Equity:Trading 10 AAPL
|
||||||
|
Equity:Trading -1000 USD
|
||||||
|
```
|
||||||
|
|
||||||
|
market equivalent:
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Account1 1000 USD
|
||||||
|
Account2 -1000 USD
|
||||||
|
Equity:Trading 0
|
||||||
|
2001-01-01 *
|
||||||
|
Account1 500 USD
|
||||||
|
Equity:Trading -500 USD
|
||||||
|
2001-01-01 *
|
||||||
|
Account1 -2000 USD
|
||||||
|
Account2 2000 USD
|
||||||
|
Income:Gains -1000 USD
|
||||||
|
Equity:Trading 1000 USD
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
* First:
|
||||||
|
* Account1 ~ 10 AAPL
|
||||||
|
* Account2 ~ -1000 USD
|
||||||
|
* Equity:Trading ~ 1000 USD - 10 AAPL
|
||||||
|
* Second:
|
||||||
|
* Account1 ~ 0
|
||||||
|
* Account2 ~ 1000 USD
|
||||||
|
* Income:Gains ~ -1000 USD
|
||||||
|
* Equity:Trading ~ -1000
|
||||||
|
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Account1: 12 EUR @@ 10 USD
|
||||||
|
Account2: -10 USD
|
||||||
|
Equity:CurrencyConversion -12 EUR
|
||||||
|
Equity:CurrencyConversion 10 USD
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account1: -10 EUR @@ 10 USD
|
||||||
|
Account2: 10 USD
|
||||||
|
Equity:CurrencyConversion -10 USD
|
||||||
|
Equity:CurrencyConversion 10 EUR
|
||||||
|
```
|
||||||
|
* First
|
||||||
|
* Account1 ~ 12 EUR
|
||||||
|
* Account2 ~ -10 USD
|
||||||
|
* Equity:CurrencyConversion ~ 10 USD - 12 EUR
|
||||||
|
* Second
|
||||||
|
* Account1 ~ 2 EUR
|
||||||
|
* Account2 ~ 0
|
||||||
|
* Equity:CurrencyConversion ~ -2 EUR
|
||||||
|
|
||||||
|
```
|
||||||
|
commodity USD
|
||||||
|
currency: "US Dollar"
|
||||||
|
code: "USD"
|
||||||
|
number: 840
|
||||||
|
decimals: 2
|
||||||
|
conversion-account: Equity:CurrencyConversion
|
||||||
|
aliases: ["$", "US$"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
; Inventory specifying cost basis and current price
|
||||||
|
2000-01-01 *
|
||||||
|
; Current market value conversion == $1100
|
||||||
|
Assets1 10 AAPL {100 USD} @ 110 USD
|
||||||
|
Assets2 -1000 USD
|
||||||
|
|
||||||
|
2000-02-01 *
|
||||||
|
Assets1 -10 AAPL {100 USD} @ 120 USD
|
||||||
|
Assets2 1200 USD
|
||||||
|
Income:CapitalGains -200
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent:
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Assets1 10 AAPL {100 USD} @ 110 USD
|
||||||
|
Assets2 -1000 USD
|
||||||
|
Equity:Trading -10 AAPL
|
||||||
|
Equity:Trading 1000 USD
|
||||||
|
|
||||||
|
2000-02-01 *
|
||||||
|
Assets1 -10 AAPL {100 USD} @ 120 USD
|
||||||
|
Assets2 1200 USD
|
||||||
|
Income:CapitalGains -200 USD
|
||||||
|
Equity:Trading 10 AAPL
|
||||||
|
Equity:Trading -1000 USD
|
||||||
|
```
|
||||||
|
|
||||||
|
Written transaction:
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Account1: 12 EUR @@ 10 USD
|
||||||
|
Account2: -10 USD
|
||||||
|
|
||||||
|
(account1 = 12 EUR, account2 = -10 USD)
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account1: -10 EUR @@ 10 USD
|
||||||
|
Account2: 10 USD
|
||||||
|
|
||||||
|
(account1 = 2 EUR, account2 = 0 USD)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Leave it in equity:
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Account1: 12 EUR @ 1.2 USD
|
||||||
|
Account2: -10 USD
|
||||||
|
Equity:CurrencyConversion -12 EUR
|
||||||
|
Equity:CurrencyConversion 10 USD
|
||||||
|
|
||||||
|
(account1 = 12 EUR, account2 = -10 USD, equity = 10 USD - 12 EUR)
|
||||||
|
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account1: -10 EUR @ 1 USD
|
||||||
|
Account2: 10 USD
|
||||||
|
Equity:CurrencyConversion -10 USD
|
||||||
|
Equity:CurrencyConversion 10 EUR
|
||||||
|
|
||||||
|
(account1 = 2 EUR, account2 = 0 USD, equity = 2 EUR)
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Account1: 12 EUR @ 1.2 USD
|
||||||
|
Account2: -10 USD
|
||||||
|
Equity:CurrencyConversion -12 EUR
|
||||||
|
Equity:CurrencyConversion 10 USD
|
||||||
|
|
||||||
|
(account1 = 12 EUR, account2 = -10 USD, equity = 10 USD - 12 EUR)
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account1: -10 EUR {1.2 USD} @ 1 USD
|
||||||
|
Account2: 10 USD
|
||||||
|
Income:CurrencyConversion -1.67 USD
|
||||||
|
Equity:CurrencyConversion -8.33 USD
|
||||||
|
Equity:CurrencyConversion 10 EUR
|
||||||
|
|
||||||
|
(account1 = 2 EUR, account2 = 0 USD, income = -1.67 USD, equity = 1.67 USD - 2 EUR)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Triple currency:
|
||||||
|
Written transaction:
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Account2: 12 EUR @ 1.2 USD
|
||||||
|
Account1: -10 USD
|
||||||
|
|
||||||
|
(account1 = -10 USD, account2 = 12 EUR, account2 = -10 USD)
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account3: 11 GBP
|
||||||
|
Account2: -10 EUR @ 1.1 GBP
|
||||||
|
|
||||||
|
(account1 = -10 USD, account2 = 2 EUR account3 = 11 GBP)
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account1: 10 USD
|
||||||
|
Account3: -10 GBP @ 1 USD
|
||||||
|
|
||||||
|
(account1 = 0 USD, account2 = 2 EUR account3 = 1 GBP)
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
2000-01-01 *
|
||||||
|
Account2: 12 EUR @ 1.2 USD
|
||||||
|
Account1: -10 USD
|
||||||
|
Equity:CurrencyConversion -12 EUR {1.2 USD}
|
||||||
|
Equity:CurrencyConversion 10 USD
|
||||||
|
|
||||||
|
(account1 = -10 USD, account2 = 12 EUR, account2 = -10 USD, equity = 10 USD - 12 EUR)
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account3: 11 GBP
|
||||||
|
Account2: -10 EUR @ 1.1 GBP
|
||||||
|
Income:CurrencyConversion 10 EUR {now} - 10 EUR {1.2 USD}
|
||||||
|
Income:CurrencyConversion -10 EUR {1.2 USD}
|
||||||
|
Equity:CurrencyConversion -11 GBP
|
||||||
|
Equity:CurrencyConversion 10 EUR {1.2 USD}
|
||||||
|
|
||||||
|
(account1 = -10 USD, account2 = 2 EUR account3 = 11 GBP, equity = 10 USD - 2 EUR - 11 GBP, income = 10 EUR {now} - 10 EUR {1.2 USD})
|
||||||
|
|
||||||
|
2002-01-01 *
|
||||||
|
Account1: 10 USD
|
||||||
|
Account3: -10 GBP @ 1 USD
|
||||||
|
...
|
||||||
|
(account1 = 0 USD, account2 = 2 EUR account3 = 1 GBP)
|
||||||
|
```
|
||||||
4
rustfmt.toml
Normal file
4
rustfmt.toml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# [format]
|
||||||
|
# max_width = 120
|
||||||
|
struct_lit_width = 50
|
||||||
|
# use_small_heuristics = "Max"
|
||||||
63
src/core/account.rs
Normal file
63
src/core/account.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
use super::{common::generate_id, CoreError};
|
||||||
|
|
||||||
|
// const ACCOUNT_SEPERATOR: char = ':';
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Account {
|
||||||
|
id: u32,
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
open_date: Option<NaiveDate>,
|
||||||
|
close_date: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
pub fn new(name: String, open_date: Option<NaiveDate>) -> Result<Account, CoreError> {
|
||||||
|
let id = generate_id();
|
||||||
|
return Ok(Account { id, name, open_date, close_date: None });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_id(&self) -> u32 {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_name(&self) -> &String {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_open_date(&self) -> Option<NaiveDate> {
|
||||||
|
self.open_date
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_close_date(&self) -> Option<NaiveDate> {
|
||||||
|
self.close_date
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn split_name_groups(&self) -> Vec<&str> {
|
||||||
|
self.name.split(":").collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_under_account(&self, account_name: &str) -> bool {
|
||||||
|
self.name
|
||||||
|
.strip_prefix(account_name)
|
||||||
|
.map(|n| n.is_empty() || n.starts_with(":"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_is_under_account() {
|
||||||
|
let subaccount = Account::new("Account1:Sub".into(), None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(subaccount.is_under_account("Account1"), true);
|
||||||
|
assert_eq!(subaccount.is_under_account("Account1:Sub"), true);
|
||||||
|
assert_eq!(subaccount.is_under_account("Account"), false);
|
||||||
|
assert_eq!(subaccount.is_under_account("Account1:S"), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/core/amounts.rs
Normal file
93
src/core/amounts.rs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
use super::{common::generate_id, CoreError};
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||||
|
pub struct Amount {
|
||||||
|
pub value: Decimal,
|
||||||
|
pub unit_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[derive(Debug, PartialEq, Clone, Copy)]
|
||||||
|
// pub struct Cost {
|
||||||
|
// pub value: Decimal,
|
||||||
|
// pub unit_id: u32,
|
||||||
|
// pub date: NativeDate,
|
||||||
|
// pub label: Option<String>,
|
||||||
|
// }
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UnitSymbol {
|
||||||
|
pub symbol: String,
|
||||||
|
pub is_prefix: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Unit {
|
||||||
|
id: u32,
|
||||||
|
symbols: Vec<UnitSymbol>,
|
||||||
|
scale: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Amount {
|
||||||
|
pub fn at_opt_price(&self, price: Option<Amount>) -> Amount {
|
||||||
|
if let Some(p) = price {
|
||||||
|
Amount {
|
||||||
|
value: self.value * p.value,
|
||||||
|
unit_id: p.unit_id,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Unit {
|
||||||
|
pub fn new(symbol: UnitSymbol) -> Result<Unit, CoreError> {
|
||||||
|
Self::new_multi_symbol(vec![symbol], None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_multi_symbol(
|
||||||
|
symbols: Vec<UnitSymbol>,
|
||||||
|
scale: Option<u32>,
|
||||||
|
) -> Result<Unit, CoreError> {
|
||||||
|
if symbols.len() == 0 {
|
||||||
|
return Err("Unit must have at least one symbol".into());
|
||||||
|
}
|
||||||
|
let id = generate_id();
|
||||||
|
Ok(Unit { id, symbols, scale })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_id(&self) -> u32 {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_scale(&self) -> Option<u32> {
|
||||||
|
self.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_symbols(&self) -> &Vec<UnitSymbol> {
|
||||||
|
&self.symbols
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches_symbol(&self, symbol: &str) -> bool {
|
||||||
|
self.symbols.iter().any(|s| s.symbol == symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_symbol(&self) -> &UnitSymbol {
|
||||||
|
&self.symbols[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn combine_amounts(amounts: impl Iterator<Item = Amount>) -> Vec<Amount> {
|
||||||
|
let mut output_amounts = HashMap::<u32, Decimal>::new();
|
||||||
|
|
||||||
|
for amount in amounts {
|
||||||
|
*output_amounts.entry(amount.unit_id).or_insert(dec!(0)) += amount.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
output_amounts.iter().map(|(&unit_id, &value)| Amount {value, unit_id}).collect()
|
||||||
|
}
|
||||||
7
src/core/common.rs
Normal file
7
src/core/common.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
use std::sync::atomic;
|
||||||
|
|
||||||
|
pub fn generate_id() -> u32 {
|
||||||
|
static COUNTER: atomic::AtomicU32 = atomic::AtomicU32::new(1);
|
||||||
|
|
||||||
|
COUNTER.fetch_add(1, atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
37
src/core/errors.rs
Normal file
37
src/core/errors.rs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
pub struct CoreError {
|
||||||
|
text: StringData,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StringData {
|
||||||
|
Static(&'static str),
|
||||||
|
Dynamic(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&'static str> for CoreError {
|
||||||
|
fn from(s: &'static str) -> Self {
|
||||||
|
CoreError { text: StringData::Static(s) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for CoreError {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
CoreError { text: StringData::Dynamic(s) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CoreError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match &self.text {
|
||||||
|
StringData::Static(s) => f.write_str(s),
|
||||||
|
StringData::Dynamic(s) => f.write_str(&s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for CoreError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
fmt::Display::fmt(self, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/core/ledger.rs
Normal file
178
src/core/ledger.rs
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
use super::{Account, Amount, CoreError, Transaction, Unit};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Ledger {
|
||||||
|
accounts: Vec<Account>,
|
||||||
|
units: Vec<Unit>,
|
||||||
|
transactions: Vec<Transaction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ledger {
|
||||||
|
pub fn new() -> Ledger {
|
||||||
|
Ledger {
|
||||||
|
accounts: Vec::new(),
|
||||||
|
units: Vec::new(),
|
||||||
|
transactions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_accounts(&self) -> &Vec<Account> {
|
||||||
|
&self.accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_account(&self, id: u32) -> Option<&Account> {
|
||||||
|
self.accounts.iter().find(|account| account.get_id() == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_account_by_name(&self, name: &str) -> Option<&Account> {
|
||||||
|
self.accounts.iter().find(|account| account.get_name() == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_units(&self) -> &Vec<Unit> {
|
||||||
|
&self.units
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_unit(&self, id: u32) -> Option<&Unit> {
|
||||||
|
self.units.iter().find(|unit| unit.get_id() == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_unit_by_symbol(&self, unit_symbol: &str) -> Option<&Unit> {
|
||||||
|
self.units.iter().find(|unit| unit.matches_symbol(unit_symbol))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_transactions(&self) -> &Vec<Transaction> {
|
||||||
|
&self.transactions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn round_amount(&self, amount: &Amount) -> Amount {
|
||||||
|
let mut new_amount = *amount;
|
||||||
|
let unit = self.get_unit(amount.unit_id);
|
||||||
|
|
||||||
|
if let Some(scale) = unit.and_then(|u| u.get_scale()) {
|
||||||
|
new_amount.value = new_amount.value.round_dp(scale);
|
||||||
|
} else {
|
||||||
|
new_amount.value = new_amount.value.normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
new_amount
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn round_amounts(&self, amounts: &[Amount]) -> Vec<Amount> {
|
||||||
|
amounts.iter().map(|a| self.round_amount(a)).filter(|a| a.value != dec!(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_account(&mut self, account: Account) -> Result<(), CoreError> {
|
||||||
|
if self
|
||||||
|
.accounts
|
||||||
|
.iter()
|
||||||
|
.any(|existing_account| existing_account.get_name() == account.get_name())
|
||||||
|
{
|
||||||
|
return Err("Account with the same name already exists".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.accounts.push(account);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_unit(&mut self, unit: Unit) -> Result<(), CoreError> {
|
||||||
|
if self.units.iter().any(|existing_unit| {
|
||||||
|
unit.get_symbols()
|
||||||
|
.iter()
|
||||||
|
.any(|new_symbol| existing_unit.matches_symbol(&new_symbol.symbol))
|
||||||
|
}) {
|
||||||
|
return Err("Unit with the same symbol already exists".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.units.push(unit);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_transaction(&mut self, transaction: Transaction) -> Result<(), CoreError> {
|
||||||
|
let balances = transaction.remaining_balance(true);
|
||||||
|
let balances = self.round_amounts(&balances);
|
||||||
|
|
||||||
|
if balances.len() != 0 {
|
||||||
|
println!("{:?}", transaction);
|
||||||
|
println!("{:?}", balances);
|
||||||
|
return Err("Transaction is not balanced".into());
|
||||||
|
}
|
||||||
|
self.transactions.push(transaction);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::core::UnitSymbol;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_unit_same_symbol() {
|
||||||
|
let mut ledger = Ledger::new();
|
||||||
|
let unit1 = Unit::new_multi_symbol(
|
||||||
|
vec![UnitSymbol { symbol: "USD".to_string(), is_prefix: false }],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ledger.add_unit(unit1).is_ok(), true);
|
||||||
|
|
||||||
|
let unit2 = Unit::new_multi_symbol(
|
||||||
|
vec![UnitSymbol { symbol: "USD".to_string(), is_prefix: false }],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ledger.add_unit(unit2).is_err(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_unit_partial_overlap() {
|
||||||
|
let mut ledger = Ledger::new();
|
||||||
|
let unit1 = Unit::new_multi_symbol(
|
||||||
|
vec![
|
||||||
|
UnitSymbol { symbol: "USD".to_string(), is_prefix: false },
|
||||||
|
UnitSymbol { symbol: "EUR".to_string(), is_prefix: false },
|
||||||
|
UnitSymbol { symbol: "JPY".to_string(), is_prefix: false },
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ledger.add_unit(unit1).is_ok(), true);
|
||||||
|
|
||||||
|
let unit2 = Unit::new_multi_symbol(
|
||||||
|
vec![UnitSymbol { symbol: "JPY".to_string(), is_prefix: false }],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ledger.add_unit(unit2).is_err(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_unit_no_overlap() {
|
||||||
|
let mut ledger = Ledger::new();
|
||||||
|
let unit1 = Unit::new_multi_symbol(
|
||||||
|
vec![
|
||||||
|
UnitSymbol { symbol: "USD".to_string(), is_prefix: false },
|
||||||
|
UnitSymbol { symbol: "EUR".to_string(), is_prefix: false },
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ledger.add_unit(unit1).is_ok(), true);
|
||||||
|
|
||||||
|
let unit2 = Unit::new_multi_symbol(
|
||||||
|
vec![
|
||||||
|
UnitSymbol { symbol: "GBP".to_string(), is_prefix: false },
|
||||||
|
UnitSymbol { symbol: "AUD".to_string(), is_prefix: false },
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ledger.add_unit(unit2).is_ok(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/core/mod.rs
Normal file
12
src/core/mod.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
mod account;
|
||||||
|
mod amounts;
|
||||||
|
mod errors;
|
||||||
|
mod ledger;
|
||||||
|
mod transaction;
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
pub use account::*;
|
||||||
|
pub use amounts::*;
|
||||||
|
pub use errors::*;
|
||||||
|
pub use ledger::*;
|
||||||
|
pub use transaction::*;
|
||||||
227
src/core/transaction.rs
Normal file
227
src/core/transaction.rs
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::Amount;
|
||||||
|
use crate::core::CoreError;
|
||||||
|
|
||||||
|
////////////////
|
||||||
|
// Directives //
|
||||||
|
////////////////
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Transaction {
|
||||||
|
date: NaiveDate,
|
||||||
|
flag: TransactionFlag,
|
||||||
|
payee: Option<String>,
|
||||||
|
narration: Option<String>,
|
||||||
|
postings: Vec<Posting>,
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////
|
||||||
|
// Sub types //
|
||||||
|
///////////////
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum TransactionFlag {
|
||||||
|
Complete,
|
||||||
|
Incomplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub struct Posting {
|
||||||
|
account_id: u32,
|
||||||
|
amount: Amount,
|
||||||
|
cost: Option<Amount>,
|
||||||
|
price: Option<Amount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////
|
||||||
|
// Implementations //
|
||||||
|
/////////////////////
|
||||||
|
|
||||||
|
impl Transaction {
|
||||||
|
pub fn new(
|
||||||
|
date: NaiveDate,
|
||||||
|
flag: TransactionFlag,
|
||||||
|
mut payee: Option<String>,
|
||||||
|
mut narration: Option<String>,
|
||||||
|
postings: Vec<Posting>,
|
||||||
|
) -> Result<Transaction, CoreError> {
|
||||||
|
if let Some(p) = &payee {
|
||||||
|
if p.trim().is_empty() {
|
||||||
|
payee = None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(n) = &narration {
|
||||||
|
if n.trim().is_empty() {
|
||||||
|
narration = None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if postings.len() == 0 {
|
||||||
|
return Err("Transaction has no postings".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Transaction { date, flag, payee, narration, postings })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_date(&self) -> NaiveDate {
|
||||||
|
self.date
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_flag(&self) -> TransactionFlag {
|
||||||
|
self.flag
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_payee(&self) -> &Option<String> {
|
||||||
|
&self.payee
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_narration(&self) -> &Option<String> {
|
||||||
|
&self.narration
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_postings(&self) -> &Vec<Posting> {
|
||||||
|
&self.postings
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remaining_balance(&self, by_cost: bool) -> Vec<Amount> {
|
||||||
|
let mut amounts = HashMap::new();
|
||||||
|
for posting in &self.postings {
|
||||||
|
let cost = if by_cost {
|
||||||
|
posting.cost.or(posting.price)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let amount = posting.amount.at_opt_price(cost);
|
||||||
|
*amounts.entry(amount.unit_id).or_insert(dec!(0)) += amount.value;
|
||||||
|
}
|
||||||
|
amounts
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, &value)| value != dec!(0))
|
||||||
|
.map(|(&unit_id, &value)| Amount { value, unit_id })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pub fn is_balanced(&self, by_cost: bool) -> bool {
|
||||||
|
// self.remaining_balance(by_cost).len() == 0
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSACTION_FLAGS: &[(TransactionFlag, char)] = &[
|
||||||
|
(TransactionFlag::Complete, '*'),
|
||||||
|
(TransactionFlag::Incomplete, '!'),
|
||||||
|
];
|
||||||
|
|
||||||
|
impl TryFrom<char> for TransactionFlag {
|
||||||
|
type Error = CoreError;
|
||||||
|
fn try_from(c: char) -> Result<Self, Self::Error> {
|
||||||
|
TRANSACTION_FLAGS
|
||||||
|
.iter()
|
||||||
|
.find(|(_, flag_c)| c == *flag_c)
|
||||||
|
.map(|(flag, _)| flag.clone())
|
||||||
|
.ok_or(CoreError::from("Invalid flag character"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Option<char>> for TransactionFlag {
|
||||||
|
type Error = CoreError;
|
||||||
|
fn try_from(c: Option<char>) -> Result<Self, Self::Error> {
|
||||||
|
c.map(|c| TransactionFlag::try_from(c))
|
||||||
|
.unwrap_or(Ok(TransactionFlag::Complete))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TransactionFlag> for char {
|
||||||
|
fn from(flag: TransactionFlag) -> Self {
|
||||||
|
TRANSACTION_FLAGS
|
||||||
|
.iter()
|
||||||
|
.find(|(f, _)| *f == flag)
|
||||||
|
.map(|(_, flag_c)| flag_c)
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Posting {
|
||||||
|
pub fn new(
|
||||||
|
account_id: u32,
|
||||||
|
amount: Amount,
|
||||||
|
cost: Option<Amount>,
|
||||||
|
price: Option<Amount>,
|
||||||
|
) -> Result<Posting, CoreError> {
|
||||||
|
if cost.is_some() && cost.unwrap().value.is_sign_negative() {
|
||||||
|
return Err("Cost cannot be negative".into());
|
||||||
|
}
|
||||||
|
if price.is_some() && price.unwrap().value.is_sign_negative() {
|
||||||
|
return Err("Price cannot be negative".into());
|
||||||
|
}
|
||||||
|
Ok(Posting { account_id, amount, cost, price })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_account_id(&self) -> u32 {
|
||||||
|
self.account_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_amount(&self) -> &Amount {
|
||||||
|
&self.amount
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cost(&self) -> &Option<Amount> {
|
||||||
|
&self.cost
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_price(&self) -> &Option<Amount> {
|
||||||
|
&self.price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////
|
||||||
|
// Tests //
|
||||||
|
/////////////
|
||||||
|
|
||||||
|
// #[cfg(test)]
|
||||||
|
// mod tests {
|
||||||
|
// use super::*;
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn balanced_transaction_simple() {
|
||||||
|
// let txn = Transaction::new(
|
||||||
|
// NaiveDate::from_ymd_opt(2000, 01, 01).unwrap(),
|
||||||
|
// TransactionFlag::Complete,
|
||||||
|
// None,
|
||||||
|
// None,
|
||||||
|
// vec![
|
||||||
|
// Posting::new(0, Amount { value: dec!(10), unit_id: 10 }, None, None).unwrap(),
|
||||||
|
// Posting::new(0, Amount { value: dec!(-10), unit_id: 10 }, None, None).unwrap(),
|
||||||
|
// ],
|
||||||
|
// )
|
||||||
|
// .unwrap();
|
||||||
|
|
||||||
|
// assert_eq!(txn.is_balanced(true), true);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn balanced_transaction_cost() {
|
||||||
|
// let txn = Transaction::new(
|
||||||
|
// NaiveDate::from_ymd_opt(2000, 01, 01).unwrap(),
|
||||||
|
// TransactionFlag::Complete,
|
||||||
|
// None,
|
||||||
|
// None,
|
||||||
|
// vec![
|
||||||
|
// Posting::new(0, Amount { value: dec!(10), unit_id: 10 }, None, None).unwrap(),
|
||||||
|
// Posting::new(
|
||||||
|
// 0,
|
||||||
|
// Amount { value: dec!(-2), unit_id: 11 },
|
||||||
|
// Some(Amount { value: dec!(5), unit_id: 10 }),
|
||||||
|
// None,
|
||||||
|
// )
|
||||||
|
// .unwrap(),
|
||||||
|
// ],
|
||||||
|
// )
|
||||||
|
// .unwrap();
|
||||||
|
|
||||||
|
// assert_eq!(txn.is_balanced(true), true);
|
||||||
|
// assert_eq!(txn.is_balanced(false), false);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
83
src/document/directives.rs
Normal file
83
src/document/directives.rs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
use crate::core::UnitSymbol;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Directives {
|
||||||
|
pub includes: Vec<IncludeDirective>,
|
||||||
|
pub commodities: Vec<CommodityDirective>,
|
||||||
|
pub transactions: Vec<TransactionDirective>,
|
||||||
|
pub balances: Vec<BalanceDirective>,
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////
|
||||||
|
// Directives //
|
||||||
|
////////////////
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IncludeDirective {
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommodityDirective {
|
||||||
|
pub date: Option<NaiveDate>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub symbols: Vec<UnitSymbol>,
|
||||||
|
pub precision: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TransactionDirective {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub flag: Option<char>,
|
||||||
|
pub payee: Option<String>,
|
||||||
|
pub narration: Option<String>,
|
||||||
|
pub postings: Vec<DirectivePosting>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BalanceDirective {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub account: String,
|
||||||
|
pub amounts: Vec<DirectiveAmount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////
|
||||||
|
// Sub-types //
|
||||||
|
///////////////
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
|
pub struct DirectivePosting {
|
||||||
|
pub account: String,
|
||||||
|
pub amount: Option<DirectiveAmount>,
|
||||||
|
pub cost: Option<DirectiveAmount>,
|
||||||
|
pub price: Option<DirectiveAmount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
|
pub struct DirectiveAmount {
|
||||||
|
pub value: Decimal,
|
||||||
|
pub unit_symbol: String,
|
||||||
|
pub is_unit_prefix: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////
|
||||||
|
// Implementations //
|
||||||
|
/////////////////////
|
||||||
|
|
||||||
|
impl Directives {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Directives{includes: Vec::new(), commodities: Vec::new(), transactions: Vec::new(), balances: Vec::new()}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_directives(&mut self, other: &Directives) {
|
||||||
|
self.includes.extend(other.includes.clone());
|
||||||
|
self.transactions.extend(other.transactions.clone());
|
||||||
|
self.balances.extend(other.balances.clone());
|
||||||
|
self.commodities.extend(other.commodities.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
210
src/document/ledger.rs
Normal file
210
src/document/ledger.rs
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
use std::{cmp::max, collections::HashMap, hash::Hash, time::Instant};
|
||||||
|
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
core::{
|
||||||
|
Account, Amount, CoreError, Ledger, Posting, Transaction, TransactionFlag, Unit, UnitSymbol,
|
||||||
|
},
|
||||||
|
queries::{self, Query},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{BalanceDirective, DirectiveAmount, TransactionDirective};
|
||||||
|
|
||||||
|
pub fn add_transaction(
|
||||||
|
ledger: &mut Ledger,
|
||||||
|
transaction: &TransactionDirective,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
|
let flag = TransactionFlag::try_from(transaction.flag)?;
|
||||||
|
|
||||||
|
let mut incomplete_postings = Vec::with_capacity(transaction.postings.len());
|
||||||
|
for p in &transaction.postings {
|
||||||
|
let account = ledger.get_account_by_name(&p.account);
|
||||||
|
|
||||||
|
// TODO: don't create account
|
||||||
|
let account_id = if let Some(a) = account {
|
||||||
|
a.get_id()
|
||||||
|
} else {
|
||||||
|
let new_account = Account::new((&p.account).into(), None)?;
|
||||||
|
let new_account_id = new_account.get_id();
|
||||||
|
ledger.add_account(new_account)?;
|
||||||
|
new_account_id
|
||||||
|
};
|
||||||
|
|
||||||
|
let amount = if let Some(a) = &p.amount {
|
||||||
|
Some(create_amount(ledger, a)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let cost = if let Some(c) = &p.cost {
|
||||||
|
Some(create_amount(ledger, c)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let price = if let Some(p) = &p.price {
|
||||||
|
Some(create_amount(ledger, p)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
incomplete_postings.push(IncompletePosting { account_id, amount, cost, price });
|
||||||
|
}
|
||||||
|
|
||||||
|
let postings = complete_incomplete_postings(&ledger, &incomplete_postings)?;
|
||||||
|
|
||||||
|
ledger.add_transaction(Transaction::new(
|
||||||
|
transaction.date,
|
||||||
|
flag,
|
||||||
|
transaction.payee.as_ref().map(|p| p.into()),
|
||||||
|
transaction.narration.as_ref().map(|n| n.into()),
|
||||||
|
postings,
|
||||||
|
)?)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_balance(ledger: &Ledger, balance: &BalanceDirective) -> Result<(), CoreError> {
|
||||||
|
let accounts = queries::balance(&ledger, &[Query::EndDate(balance.date)]);
|
||||||
|
|
||||||
|
let accounts = accounts.iter().filter(|(&account_id, val)| {
|
||||||
|
let account = ledger.get_account(account_id).unwrap();
|
||||||
|
account.is_under_account(&balance.account)
|
||||||
|
});
|
||||||
|
|
||||||
|
if accounts.clone().count() == 0 {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut total_amounts = HashMap::new();
|
||||||
|
let mut account_count = 0;
|
||||||
|
|
||||||
|
for (_, amounts) in accounts {
|
||||||
|
account_count += 1;
|
||||||
|
for amount in amounts {
|
||||||
|
*total_amounts.entry(amount.unit_id).or_insert(dec!(0)) += amount.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if account_count == 0 {
|
||||||
|
return Err("No accounts match balance account".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// let balance_account = ledger
|
||||||
|
// .get_account_by_name(&balance.account)
|
||||||
|
// .ok_or("Account not found")?;
|
||||||
|
|
||||||
|
// let amounts = accounts
|
||||||
|
// .get(&balance_account.get_id())
|
||||||
|
// .map(|v| v.as_slice())
|
||||||
|
// .unwrap_or(&[]);
|
||||||
|
|
||||||
|
// if amounts.len() > balance.amounts.len() {
|
||||||
|
// return Err("".into());
|
||||||
|
// } else if amounts.len() < balance.amounts.len() {
|
||||||
|
// return Err("".into());
|
||||||
|
// }
|
||||||
|
|
||||||
|
for balance_amount in &balance.amounts {
|
||||||
|
let unit = ledger
|
||||||
|
.get_unit_by_symbol(&balance_amount.unit_symbol)
|
||||||
|
.ok_or("Unit not found")?;
|
||||||
|
let value = total_amounts.get(&unit.get_id()).map(|v| *v).unwrap_or(dec!(0));
|
||||||
|
|
||||||
|
// let value = amounts
|
||||||
|
// .iter()
|
||||||
|
// .find(|a| a.unit_id == unit.get_id())
|
||||||
|
// .map(|a| a.value)
|
||||||
|
// .unwrap_or(dec!(0));
|
||||||
|
let max_scale = max(value.scale(), balance_amount.value.scale());
|
||||||
|
|
||||||
|
let value = value.round_dp(max_scale);
|
||||||
|
let balance_value = balance_amount.value.round_dp(max_scale);
|
||||||
|
|
||||||
|
if value != balance_value {
|
||||||
|
return Err(format!(
|
||||||
|
"Balance amount for \"{}\" on {} does not match. Expected {} but got {}",
|
||||||
|
balance.account, balance.date, balance_value, value
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IncompletePosting {
|
||||||
|
account_id: u32,
|
||||||
|
amount: Option<Amount>,
|
||||||
|
cost: Option<Amount>,
|
||||||
|
price: Option<Amount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_amount(ledger: &mut Ledger, amount: &DirectiveAmount) -> Result<Amount, CoreError> {
|
||||||
|
let unit_id = get_or_create_unit(ledger, &amount.unit_symbol, amount.is_unit_prefix)?;
|
||||||
|
|
||||||
|
Ok(Amount { value: amount.value, unit_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_or_create_unit(
|
||||||
|
ledger: &mut Ledger,
|
||||||
|
unit_symbol: &str,
|
||||||
|
is_prefix: bool,
|
||||||
|
) -> Result<u32, CoreError> {
|
||||||
|
let unit_id = if let Some(u) = ledger.get_unit_by_symbol(unit_symbol) {
|
||||||
|
u.get_id()
|
||||||
|
} else {
|
||||||
|
let new_unit = Unit::new(UnitSymbol { symbol: unit_symbol.into(), is_prefix })?;
|
||||||
|
let new_unit_id = new_unit.get_id();
|
||||||
|
ledger.add_unit(new_unit)?;
|
||||||
|
new_unit_id
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(unit_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_incomplete_postings(
|
||||||
|
ledger: &Ledger,
|
||||||
|
postings: &Vec<IncompletePosting>,
|
||||||
|
) -> Result<Vec<Posting>, CoreError> {
|
||||||
|
let mut incomplete_postings_iter = postings.iter().filter(|p| p.amount.is_none());
|
||||||
|
let incomplete_posting_account_id = incomplete_postings_iter.next().map(|p| p.account_id);
|
||||||
|
|
||||||
|
if incomplete_postings_iter.count() > 0 {
|
||||||
|
return Err("Multiple incomplete posting amounts. Unable to infer value".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut amounts = HashMap::new();
|
||||||
|
let mut complete_postings = Vec::with_capacity(postings.len());
|
||||||
|
for posting in postings {
|
||||||
|
if let Some(amount) = posting.amount {
|
||||||
|
complete_postings.push(Posting::new(
|
||||||
|
posting.account_id,
|
||||||
|
amount,
|
||||||
|
posting.cost,
|
||||||
|
posting.price,
|
||||||
|
)?);
|
||||||
|
|
||||||
|
let cost = posting.cost.or(posting.price);
|
||||||
|
let converted_amount = amount.at_opt_price(cost);
|
||||||
|
let converted_amount = ledger.round_amount(&converted_amount);
|
||||||
|
*amounts.entry(converted_amount.unit_id).or_insert(dec!(0)) += converted_amount.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(account_id) = incomplete_posting_account_id {
|
||||||
|
for (&unit_id, &value) in amounts.iter().filter(|(_, &v)| v != dec!(0)) {
|
||||||
|
let mut value = -value;
|
||||||
|
if let Some(scale) = ledger.get_unit(unit_id).and_then(|u| u.get_scale()) {
|
||||||
|
value = value.round_dp(scale);
|
||||||
|
}
|
||||||
|
complete_postings.push(Posting::new(
|
||||||
|
account_id,
|
||||||
|
Amount { unit_id, value },
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(complete_postings)
|
||||||
|
}
|
||||||
86
src/document/mod.rs
Normal file
86
src/document/mod.rs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
mod directives;
|
||||||
|
mod parser;
|
||||||
|
mod ledger;
|
||||||
|
|
||||||
|
pub use directives::*;
|
||||||
|
use ledger::{add_transaction, check_balance};
|
||||||
|
use parser::parse_directives;
|
||||||
|
|
||||||
|
use crate::core::{CoreError, Ledger, Unit};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Document {
|
||||||
|
directives: Directives,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SingleDocument {
|
||||||
|
file_data: String,
|
||||||
|
directives: Directives,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Document {
|
||||||
|
pub fn new(file_path: &Path) -> Result<Self, CoreError> {
|
||||||
|
let mut document = Document { directives: Directives::new() };
|
||||||
|
document.add_directives_recursive(file_path)?;
|
||||||
|
|
||||||
|
Ok(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_ledger(&self) -> Result<Ledger, CoreError> {
|
||||||
|
let mut ledger = Ledger::new();
|
||||||
|
|
||||||
|
for commodity in &self.directives.commodities {
|
||||||
|
let unit = Unit::new_multi_symbol(commodity.symbols.clone(), commodity.precision)?;
|
||||||
|
ledger.add_unit(unit)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for transaction in &self.directives.transactions {
|
||||||
|
add_transaction(&mut ledger, transaction)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for balance in &self.directives.balances {
|
||||||
|
check_balance(&ledger, &balance)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ledger)
|
||||||
|
// for balance in self.directives.balances {
|
||||||
|
// ledger.add
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_directives_recursive(&mut self, file_path: &Path) -> Result<(), CoreError> {
|
||||||
|
let document = SingleDocument::new(file_path)?;
|
||||||
|
|
||||||
|
let includes: Vec<&IncludeDirective> = document
|
||||||
|
.directives
|
||||||
|
.includes
|
||||||
|
.iter()
|
||||||
|
.filter(|i1| !self.directives.includes.iter().any(|i2| i2.path == i1.path))
|
||||||
|
.collect();
|
||||||
|
self.directives.add_directives(&document.directives);
|
||||||
|
for include in includes {
|
||||||
|
let path = if include.path.is_relative() {
|
||||||
|
file_path
|
||||||
|
.parent()
|
||||||
|
.ok_or(CoreError::from("Unable to get parent path"))?
|
||||||
|
.join(&include.path)
|
||||||
|
} else {
|
||||||
|
include.path.clone()
|
||||||
|
};
|
||||||
|
self.add_directives_recursive(&path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SingleDocument {
|
||||||
|
fn new(file_path: &Path) -> Result<Self, CoreError> {
|
||||||
|
let file_data = std::fs::read_to_string(file_path)
|
||||||
|
.map_err(|e| format!("Failed to read file: {} - {}", file_path.display(), e))?;
|
||||||
|
let directives = parse_directives(&file_data)?;
|
||||||
|
Ok(SingleDocument { file_data, directives })
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/document/parser/amounts.rs
Normal file
156
src/document/parser/amounts.rs
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
use nom::{
|
||||||
|
branch::alt,
|
||||||
|
character::complete::{char, none_of, one_of, space0},
|
||||||
|
combinator::{opt, recognize},
|
||||||
|
error::{Error, ErrorKind},
|
||||||
|
multi::{many0, many1},
|
||||||
|
sequence::{preceded, terminated, tuple},
|
||||||
|
Err, IResult, InputTakeAtPosition, Parser,
|
||||||
|
};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
use crate::document::DirectiveAmount;
|
||||||
|
|
||||||
|
pub fn account(input: &str) -> IResult<&str, &str> {
|
||||||
|
input.split_at_position1_complete(|item| item == ' ' || item == '\t', ErrorKind::AlphaNumeric)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn amount(input: &str) -> IResult<&str, DirectiveAmount> {
|
||||||
|
alt((suffix_amount, prefix_amount)).parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decimal(input: &str) -> IResult<&str, Decimal> {
|
||||||
|
let (new_input, decimal_str) = recognize(tuple((
|
||||||
|
opt(one_of("+-")),
|
||||||
|
opt(number_int),
|
||||||
|
opt(char('.')),
|
||||||
|
opt(number_int),
|
||||||
|
)))
|
||||||
|
.parse(input)?;
|
||||||
|
|
||||||
|
if decimal_str.contains(',') {
|
||||||
|
match Decimal::from_str_exact(&decimal_str.replace(",", "")) {
|
||||||
|
Ok(decimal) => Ok((new_input, decimal)),
|
||||||
|
Err(_) => Err(Err::Error(Error::new(input, ErrorKind::Eof))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match Decimal::from_str_exact(decimal_str) {
|
||||||
|
Ok(decimal) => Ok((new_input, decimal)),
|
||||||
|
Err(_) => Err(Err::Error(Error::new(input, ErrorKind::Eof))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////
|
||||||
|
// Private //
|
||||||
|
///////////////
|
||||||
|
|
||||||
|
fn prefix_amount(input: &str) -> IResult<&str, DirectiveAmount> {
|
||||||
|
tuple((
|
||||||
|
opt(one_of("+-")),
|
||||||
|
unit,
|
||||||
|
preceded(space0, decimal),
|
||||||
|
))
|
||||||
|
.map(|(sign, unit_symbol, mut value)| {
|
||||||
|
if let Some(s) = sign {
|
||||||
|
if s == '-' {
|
||||||
|
value = value * dec!(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DirectiveAmount {
|
||||||
|
value,
|
||||||
|
unit_symbol: unit_symbol.to_string(),
|
||||||
|
is_unit_prefix: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suffix_amount(input: &str) -> IResult<&str, DirectiveAmount> {
|
||||||
|
tuple((
|
||||||
|
decimal,
|
||||||
|
preceded(space0, unit),
|
||||||
|
))
|
||||||
|
.map(|(value, unit_symbol)| DirectiveAmount {
|
||||||
|
value,
|
||||||
|
unit_symbol: unit_symbol.to_string(),
|
||||||
|
is_unit_prefix: false,
|
||||||
|
})
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unit(input: &str) -> IResult<&str, &str> {
|
||||||
|
recognize(many1(none_of("0123456789,+-_()*/.{} \t"))).parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number_int(input: &str) -> IResult<&str, &str> {
|
||||||
|
recognize(many1(terminated(one_of("0123456789"), many0(one_of("_,")))))(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_decimal_good() {
|
||||||
|
assert_eq!(decimal("1").unwrap().1, dec!(1));
|
||||||
|
assert_eq!(decimal("+10").unwrap().1, dec!(10));
|
||||||
|
assert_eq!(decimal("-10").unwrap().1, dec!(-10));
|
||||||
|
assert_eq!(decimal("10.1").unwrap().1, dec!(10.1));
|
||||||
|
assert_eq!(decimal("100_000.01").unwrap().1, dec!(100000.01));
|
||||||
|
assert_eq!(decimal(".1").unwrap().1, dec!(0.1));
|
||||||
|
assert_eq!(decimal("-.1").unwrap().1, dec!(-0.1));
|
||||||
|
assert_eq!(decimal("2.").unwrap().1, dec!(2.));
|
||||||
|
assert_eq!(decimal("1,000").unwrap().1, dec!(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn amount_good() {
|
||||||
|
assert_eq!(
|
||||||
|
amount("$10").unwrap().1,
|
||||||
|
DirectiveAmount {
|
||||||
|
value: dec!(10),
|
||||||
|
unit_symbol: "$".into(),
|
||||||
|
is_unit_prefix: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
amount("10 USD").unwrap().1,
|
||||||
|
DirectiveAmount {
|
||||||
|
value: dec!(10),
|
||||||
|
unit_symbol: "USD".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
amount("-$10.01").unwrap().1,
|
||||||
|
DirectiveAmount {
|
||||||
|
value: dec!(-10.01),
|
||||||
|
unit_symbol: "$".into(),
|
||||||
|
is_unit_prefix: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
amount("-10€").unwrap().1,
|
||||||
|
DirectiveAmount {
|
||||||
|
value: dec!(-10),
|
||||||
|
unit_symbol: "€".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
amount("-€10").unwrap().1,
|
||||||
|
DirectiveAmount {
|
||||||
|
value: dec!(-10),
|
||||||
|
unit_symbol: "€".into(),
|
||||||
|
is_unit_prefix: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
245
src/document/parser/base_directive.rs
Normal file
245
src/document/parser/base_directive.rs
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
use chrono::NaiveDate;
|
||||||
|
use nom::{
|
||||||
|
branch::alt,
|
||||||
|
bytes::complete::{tag, take_till, take_while_m_n},
|
||||||
|
character::complete::{line_ending, multispace0, not_line_ending, one_of, space0, space1},
|
||||||
|
combinator::{eof, opt},
|
||||||
|
error::{Error, ErrorKind},
|
||||||
|
multi::many0,
|
||||||
|
sequence::{pair, preceded, terminated, tuple},
|
||||||
|
AsChar, FindToken, IResult, InputTakeAtPosition, Parser,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BaseDirective<'a> {
|
||||||
|
pub date: Option<NaiveDate>,
|
||||||
|
pub directive_name: &'a str,
|
||||||
|
pub lines: Vec<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: clean up
|
||||||
|
pub fn base_directives(input: &str) -> IResult<&str, Vec<BaseDirective>> {
|
||||||
|
terminated(
|
||||||
|
many0(preceded(empty_lines, base_directive)),
|
||||||
|
pair(empty_lines, eof),
|
||||||
|
)
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_directive(input: &str) -> IResult<&str, BaseDirective> {
|
||||||
|
tuple((
|
||||||
|
opt(terminated(parse_iso_date, space1)),
|
||||||
|
terminated(parse_directive_name, space0),
|
||||||
|
parse_line_not_comment,
|
||||||
|
many0(preceded(
|
||||||
|
many0(alt((preceded(space0, line_ending), parse_comment))),
|
||||||
|
preceded(space1, parse_line_not_comment),
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
.map(|(date, directive_name, line0, lines)| BaseDirective {
|
||||||
|
date: date,
|
||||||
|
directive_name,
|
||||||
|
lines: {
|
||||||
|
let mut l: Vec<&str> = lines
|
||||||
|
.iter()
|
||||||
|
.map(|v| *v)
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.collect();
|
||||||
|
l.insert(0, line0);
|
||||||
|
l
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn empty_lines(input: &str) -> IResult<&str, ()> {
|
||||||
|
many0(alt((preceded(space0, line_ending), parse_comment)))
|
||||||
|
.map(|_| {})
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////
|
||||||
|
// Private //
|
||||||
|
///////////////
|
||||||
|
|
||||||
|
fn take_n_digits(i: &str, n: usize) -> IResult<&str, u32> {
|
||||||
|
let (i, digits) = take_while_m_n(n, n, AsChar::is_dec_digit)(i)?;
|
||||||
|
|
||||||
|
let res = digits.parse().expect("Invalid ASCII number");
|
||||||
|
|
||||||
|
Ok((i, res))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_year(input: &str) -> IResult<&str, i32> {
|
||||||
|
take_n_digits(input, 4).map(|(str, year)| (str, i32::try_from(year).unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_month(input: &str) -> IResult<&str, u32> {
|
||||||
|
take_n_digits(input, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_day(input: &str) -> IResult<&str, u32> {
|
||||||
|
take_n_digits(input, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_iso_date(input: &str) -> IResult<&str, NaiveDate> {
|
||||||
|
let (new_input, (year, _, month, _, day)) = tuple((
|
||||||
|
date_year,
|
||||||
|
opt(tag("-")),
|
||||||
|
date_month,
|
||||||
|
opt(tag("-")),
|
||||||
|
date_day,
|
||||||
|
))
|
||||||
|
.parse(input)?;
|
||||||
|
|
||||||
|
match NaiveDate::from_ymd_opt(year, month, day) {
|
||||||
|
Some(date) => Ok((new_input, date)),
|
||||||
|
None => Err(nom::Err::Error(Error::new(input, ErrorKind::Eof))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMENT_CHARS: &str = ";#";
|
||||||
|
|
||||||
|
fn parse_comment(input: &str) -> IResult<&str, &str> {
|
||||||
|
preceded(
|
||||||
|
multispace0,
|
||||||
|
preceded(one_of(COMMENT_CHARS), not_line_ending),
|
||||||
|
)
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_line_not_comment(input: &str) -> IResult<&str, &str> {
|
||||||
|
not_line_ending
|
||||||
|
.and_then(take_till(|c| COMMENT_CHARS.find_token(c)))
|
||||||
|
.map(|val: &str| val.trim())
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_directive_name(input: &str) -> IResult<&str, &str> {
|
||||||
|
input.split_at_position1_complete(
|
||||||
|
|item| !item.is_alphanumeric() && !item.is_ascii_punctuation(),
|
||||||
|
ErrorKind::AlphaNumeric,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////
|
||||||
|
// Tests //
|
||||||
|
/////////////
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn correct_date() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_iso_date("2000-01-01"),
|
||||||
|
Ok(("", NaiveDate::from_ymd_opt(2000, 01, 01).unwrap()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_iso_date("20000101"),
|
||||||
|
Ok(("", NaiveDate::from_ymd_opt(2000, 01, 01).unwrap()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_date() {
|
||||||
|
assert_eq!(parse_iso_date("200-01-01").is_err(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_date() {
|
||||||
|
assert_eq!(parse_iso_date("2000-02-30").is_err(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_line_directive() {
|
||||||
|
let directive = "2000-01-01 directive value";
|
||||||
|
let result = base_directive(directive).unwrap().1;
|
||||||
|
|
||||||
|
assert_eq!(result.date.is_some(), true);
|
||||||
|
assert_eq!(result.directive_name, "directive");
|
||||||
|
assert_eq!(result.lines[0], "value");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_line_directive_no_date() {
|
||||||
|
let directive = "directive value";
|
||||||
|
let result = base_directive(directive).unwrap().1;
|
||||||
|
|
||||||
|
assert_eq!(result.date.is_some(), false);
|
||||||
|
assert_eq!(result.directive_name, "directive");
|
||||||
|
assert_eq!(result.lines[0], "value");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_directives() {
|
||||||
|
let directives = concat!(
|
||||||
|
"directive value\n",
|
||||||
|
"\n",
|
||||||
|
"2000-01-01 txn\n",
|
||||||
|
" Account1 200 USD\n",
|
||||||
|
" Account2 -200 USD\n",
|
||||||
|
"\n \n",
|
||||||
|
"2000-01-01 txn\n",
|
||||||
|
" Account1 200 USD\n",
|
||||||
|
" Account2 -200 USD\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = base_directives(directives).unwrap().1;
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 3);
|
||||||
|
assert_eq!(result[0].directive_name, "directive");
|
||||||
|
assert_eq!(result[1].directive_name, "txn");
|
||||||
|
assert_eq!(result[2].lines.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_directives_extra_lines() {
|
||||||
|
let directives = concat!(
|
||||||
|
"\n",
|
||||||
|
"2000-01-01 txn\n",
|
||||||
|
" Account1 200 USD\n",
|
||||||
|
" \n",
|
||||||
|
" Account2 -200 USD\n",
|
||||||
|
"\n \n",
|
||||||
|
"2000-01-01 txn\n",
|
||||||
|
" Account1 200 USD\n",
|
||||||
|
"\n",
|
||||||
|
" Account2 -200 USD\n",
|
||||||
|
);
|
||||||
|
let result = base_directives(directives).unwrap().1;
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result[0].lines.len(), 3);
|
||||||
|
assert_eq!(result[1].lines.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_directives_comments() {
|
||||||
|
let directives = concat!(
|
||||||
|
";comment1\n",
|
||||||
|
"\n",
|
||||||
|
"2000-01-01 txn ;comment2\n",
|
||||||
|
" Account1 200 USD\n",
|
||||||
|
" \n",
|
||||||
|
" ; comment3\n",
|
||||||
|
" Account2 -200 USD\n",
|
||||||
|
"\n",
|
||||||
|
"2000-01-01 txn\n",
|
||||||
|
" Account1 200 USD ;comment 4\n",
|
||||||
|
"\n",
|
||||||
|
";comment5\n",
|
||||||
|
" Account2 -200 USD\n",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = base_directives(directives).unwrap().1;
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result[0].lines.len(), 3);
|
||||||
|
assert_eq!(result[0].lines[0], "");
|
||||||
|
assert_eq!(result[1].lines.len(), 3);
|
||||||
|
assert_eq!(result[1].lines[1], "Account1 200 USD");
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/document/parser/directives.rs
Normal file
194
src/document/parser/directives.rs
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use nom::{
|
||||||
|
bytes::complete::{is_not, tag},
|
||||||
|
character::complete::{none_of, space1},
|
||||||
|
combinator::{opt, rest},
|
||||||
|
error::{Error, ErrorKind, ParseError},
|
||||||
|
sequence::{preceded, terminated, tuple},
|
||||||
|
IResult, Parser,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{core::UnitSymbol, document::{
|
||||||
|
BalanceDirective, CommodityDirective, IncludeDirective, TransactionDirective,
|
||||||
|
}};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
amounts::{account, amount},
|
||||||
|
base_directive::BaseDirective,
|
||||||
|
transaction::transaction,
|
||||||
|
};
|
||||||
|
|
||||||
|
// use super::{
|
||||||
|
// base::ParsedBaseDirective,
|
||||||
|
// shared::{parse_account, parse_amount},
|
||||||
|
// transaction::parse_transaction,
|
||||||
|
// types::{ParseError, ParsedBalanceDirective, ParsedDirectives, ParsedIncludeDirective},
|
||||||
|
// };
|
||||||
|
|
||||||
|
//////////////
|
||||||
|
// Public //
|
||||||
|
//////////////
|
||||||
|
|
||||||
|
pub enum Directive {
|
||||||
|
Include(IncludeDirective),
|
||||||
|
Commodity(CommodityDirective),
|
||||||
|
Balance(BalanceDirective),
|
||||||
|
Transaction(TransactionDirective),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn specific_directive<'a>(
|
||||||
|
directive: BaseDirective<'a>,
|
||||||
|
) -> IResult<BaseDirective<'a>, Directive> {
|
||||||
|
match directive.directive_name.to_lowercase().as_str() {
|
||||||
|
"txn" => transaction(directive).map(|(i, v)| (i, Directive::Transaction(v))),
|
||||||
|
// Assume transaction flag if length of one
|
||||||
|
n if (n.len() == 1) => transaction
|
||||||
|
.map(|v| Directive::Transaction(v))
|
||||||
|
.parse(directive),
|
||||||
|
"include" => include_directive
|
||||||
|
.map(|v| Directive::Include(v))
|
||||||
|
.parse(directive),
|
||||||
|
"commodity" => commodity_directive
|
||||||
|
.map(|v| Directive::Commodity(v))
|
||||||
|
.parse(directive),
|
||||||
|
"balance" => balance_directive
|
||||||
|
.map(|v| Directive::Balance(v))
|
||||||
|
.parse(directive),
|
||||||
|
_ => Err(nom::Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//////// //////
|
||||||
|
// Private //
|
||||||
|
///////////////
|
||||||
|
|
||||||
|
fn include_directive(directive: BaseDirective) -> IResult<BaseDirective, IncludeDirective> {
|
||||||
|
if directive.date.is_some() {
|
||||||
|
return Err(nom::Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if directive.lines.len() != 1 {
|
||||||
|
return Err(nom::Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = PathBuf::from(directive.lines[0]);
|
||||||
|
|
||||||
|
Ok((directive, IncludeDirective { path }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commodity_directive(directive: BaseDirective) -> IResult<BaseDirective, CommodityDirective> {
|
||||||
|
let date = directive.date;
|
||||||
|
let name = if directive.lines[0].trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(directive.lines[0].trim().into())
|
||||||
|
};
|
||||||
|
let mut symbols = Vec::new();
|
||||||
|
let mut precision = None;
|
||||||
|
|
||||||
|
for &line in directive.lines.get(1..).unwrap_or(&[]) {
|
||||||
|
let (key, value) = if let Ok(v) =
|
||||||
|
tuple((terminated(is_not::<_, _, Error<&str>>(":"), tag(":")), rest)).parse(line)
|
||||||
|
{
|
||||||
|
v.1
|
||||||
|
} else {
|
||||||
|
return Err(nom::Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(nom::Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
match key {
|
||||||
|
"symbol_prefix" => symbols.push(UnitSymbol {symbol: value.into(), is_prefix: true}),
|
||||||
|
"symbol" => symbols.push(UnitSymbol {symbol: value.into(), is_prefix: false}),
|
||||||
|
"precision" => precision = Some(value.trim().parse::<u32>().unwrap()), // TODO: unwrap
|
||||||
|
_ => return Err(nom::Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
directive,
|
||||||
|
CommodityDirective { date, name, symbols, precision },
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn balance_directive(directive: BaseDirective) -> IResult<BaseDirective, BalanceDirective> {
|
||||||
|
let date = if let Some(d) = directive.date {
|
||||||
|
d
|
||||||
|
} else {
|
||||||
|
return Err(nom::Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_, (account, bal_amount)) = if let Ok(v) =
|
||||||
|
tuple((account, opt(preceded(space1, amount)))).parse(directive.lines.get(0).unwrap_or(&""))
|
||||||
|
{
|
||||||
|
v
|
||||||
|
} else {
|
||||||
|
return Err(nom::Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Eof,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
let amounts = if let Some(a) = bal_amount {
|
||||||
|
vec![a]
|
||||||
|
} else {
|
||||||
|
let mut amounts = Vec::with_capacity(directive.lines.len());
|
||||||
|
for &line in directive.lines.get(1..).unwrap_or(&[]) {
|
||||||
|
let bal_amount = if let Ok(a) = amount(line) {
|
||||||
|
a
|
||||||
|
} else {
|
||||||
|
return Err(nom::Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
amounts.push(bal_amount.1);
|
||||||
|
}
|
||||||
|
amounts
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
directive,
|
||||||
|
BalanceDirective { date, account: account.to_string(), amounts },
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_balance() {
|
||||||
|
let directive = BaseDirective {
|
||||||
|
date: NaiveDate::from_ymd_opt(2000, 01, 01),
|
||||||
|
directive_name: "txn",
|
||||||
|
lines: vec!["Account", "$10", "20 UNIT"],
|
||||||
|
};
|
||||||
|
let balance = balance_directive(directive).unwrap();
|
||||||
|
println!("{:?}", balance);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/document/parser/mod.rs
Normal file
46
src/document/parser/mod.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
mod amounts;
|
||||||
|
mod base_directive;
|
||||||
|
mod directives;
|
||||||
|
mod transaction;
|
||||||
|
|
||||||
|
use base_directive::{base_directive, empty_lines};
|
||||||
|
use directives::{specific_directive, Directive};
|
||||||
|
use nom::{
|
||||||
|
combinator::eof,
|
||||||
|
error::{Error, ErrorKind},
|
||||||
|
multi::many0,
|
||||||
|
sequence::{pair, preceded, terminated},
|
||||||
|
Parser,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::core::CoreError;
|
||||||
|
|
||||||
|
use super::Directives;
|
||||||
|
|
||||||
|
pub fn parse_directives(input: &str) -> Result<Directives, CoreError> {
|
||||||
|
let parsed_directives = terminated(
|
||||||
|
many0(preceded(
|
||||||
|
empty_lines,
|
||||||
|
base_directive.and_then(|d| {
|
||||||
|
specific_directive(d)
|
||||||
|
.map_err(|_| nom::Err::Failure(Error { input: "", code: ErrorKind::Fail }))
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
pair(empty_lines, eof),
|
||||||
|
)
|
||||||
|
.parse(input)
|
||||||
|
.map_err(|e| CoreError::from(e.to_string()))?
|
||||||
|
.1;
|
||||||
|
|
||||||
|
let mut directives = Directives::new();
|
||||||
|
for parsed_directive in parsed_directives {
|
||||||
|
match parsed_directive {
|
||||||
|
Directive::Include(d) => directives.includes.push(d),
|
||||||
|
Directive::Balance(d) => directives.balances.push(d),
|
||||||
|
Directive::Transaction(d) => directives.transactions.push(d),
|
||||||
|
Directive::Commodity(d) => directives.commodities.push(d),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(directives)
|
||||||
|
}
|
||||||
365
src/document/parser/transaction.rs
Normal file
365
src/document/parser/transaction.rs
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
use nom::{
|
||||||
|
branch::alt,
|
||||||
|
bytes::complete::{is_not, tag},
|
||||||
|
character::complete::space1,
|
||||||
|
combinator::{eof, opt, rest},
|
||||||
|
error::{Error, ErrorKind},
|
||||||
|
sequence::{delimited, preceded, tuple},
|
||||||
|
Err, IResult, Parser,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::document::{DirectiveAmount, DirectivePosting, TransactionDirective};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
amounts::{account, amount},
|
||||||
|
base_directive::BaseDirective,
|
||||||
|
};
|
||||||
|
|
||||||
|
// use super::{
|
||||||
|
// base::ParsedBaseDirective, directives::BaseDirective, shared::{parse_account, amount}, types::{ParseError, DirectiveAmount, DirectivePosting, ParsedTransactionDirective}
|
||||||
|
// };
|
||||||
|
|
||||||
|
//////////////
|
||||||
|
// Public //
|
||||||
|
//////////////
|
||||||
|
|
||||||
|
pub fn transaction<'a>(
|
||||||
|
directive: BaseDirective<'a>,
|
||||||
|
) -> IResult<BaseDirective<'a>, TransactionDirective> {
|
||||||
|
let date = if let Some(d) = directive.date {
|
||||||
|
d
|
||||||
|
} else {
|
||||||
|
return Result::Err(Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
let flag = if directive.directive_name.len() == 1 {
|
||||||
|
Some(directive.directive_name.chars().next().unwrap())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_, (payee, narration)) =
|
||||||
|
if let Ok(v) = payee_narration(directive.lines.get(0).unwrap_or(&"")) {
|
||||||
|
v
|
||||||
|
} else {
|
||||||
|
return Result::Err(Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut postings = Vec::with_capacity(directive.lines.len());
|
||||||
|
for &line in directive.lines.get(1..).unwrap_or(&[]) {
|
||||||
|
let posting = if let Ok(v) = posting(line) {
|
||||||
|
v
|
||||||
|
} else {
|
||||||
|
return Result::Err(Err::Failure(Error {
|
||||||
|
input: directive,
|
||||||
|
code: ErrorKind::Fail,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
postings.push(posting.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
directive,
|
||||||
|
TransactionDirective {
|
||||||
|
date,
|
||||||
|
flag,
|
||||||
|
payee: payee.map(|p| p.to_string()),
|
||||||
|
narration: narration.map(|n| n.to_string()),
|
||||||
|
postings,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////
|
||||||
|
// Private //
|
||||||
|
///////////////
|
||||||
|
|
||||||
|
fn payee_narration(input: &str) -> IResult<&str, (Option<&str>, Option<&str>)> {
|
||||||
|
tuple((opt(is_not("|")), opt(preceded(tag("|"), rest))))
|
||||||
|
.map(|(payee, narration): (Option<&str>, Option<&str>)| {
|
||||||
|
(
|
||||||
|
payee.map(|p| p.trim()).filter(|p| !p.is_empty()),
|
||||||
|
narration.map(|n| n.trim()).filter(|n| !n.is_empty()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn posting(input: &str) -> IResult<&str, DirectivePosting> {
|
||||||
|
tuple((
|
||||||
|
account,
|
||||||
|
opt(tuple((
|
||||||
|
preceded(space1, amount),
|
||||||
|
opt(preceded(space1, parse_cost)),
|
||||||
|
opt(preceded(space1, parse_price)),
|
||||||
|
))),
|
||||||
|
eof,
|
||||||
|
))
|
||||||
|
.map(|(account, value, _)| {
|
||||||
|
let mut amount = None;
|
||||||
|
let mut cost = None;
|
||||||
|
let mut price = None;
|
||||||
|
if let Some(v) = value {
|
||||||
|
amount = Some(v.0);
|
||||||
|
if let Some(c) = v.1 {
|
||||||
|
if c.1 {
|
||||||
|
cost = Some(DirectiveAmount {
|
||||||
|
value: c.0.value / amount.as_ref().unwrap().value.abs(),
|
||||||
|
unit_symbol: c.0.unit_symbol,
|
||||||
|
is_unit_prefix: c.0.is_unit_prefix,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cost = Some(c.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(p) = v.2 {
|
||||||
|
if p.1 {
|
||||||
|
price = Some(DirectiveAmount {
|
||||||
|
value: p.0.value / amount.as_ref().unwrap().value.abs(),
|
||||||
|
unit_symbol: p.0.unit_symbol,
|
||||||
|
is_unit_prefix: p.0.is_unit_prefix,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
price = Some(p.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DirectivePosting { account: account.to_string(), amount, cost, price }
|
||||||
|
})
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cost(input: &str) -> IResult<&str, (DirectiveAmount, bool)> {
|
||||||
|
alt((
|
||||||
|
delimited(tag("{"), amount, tag("}")).map(|amount| (amount, false)),
|
||||||
|
delimited(tag("{{"), amount, tag("}}")).map(|amount| (amount, true)),
|
||||||
|
))
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_price(input: &str) -> IResult<&str, (DirectiveAmount, bool)> {
|
||||||
|
alt((
|
||||||
|
preceded(tuple((tag("@"), space1)), amount).map(|amount| (amount, false)),
|
||||||
|
preceded(tuple((tag("@@"), space1)), amount).map(|amount| (amount, true)),
|
||||||
|
))
|
||||||
|
.parse(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////
|
||||||
|
// Tests //
|
||||||
|
/////////////
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_payee_narration() {
|
||||||
|
assert_eq!(payee_narration("").unwrap().1, (None, None));
|
||||||
|
assert_eq!(payee_narration("|").unwrap().1, (None, None));
|
||||||
|
assert_eq!(payee_narration("aa").unwrap().1, (Some("aa"), None));
|
||||||
|
assert_eq!(payee_narration("aa |").unwrap().1, (Some("aa"), None));
|
||||||
|
assert_eq!(
|
||||||
|
payee_narration("aa | bb").unwrap().1,
|
||||||
|
(Some("aa"), Some("bb"))
|
||||||
|
);
|
||||||
|
assert_eq!(payee_narration(" | bb").unwrap().1, (None, Some("bb")));
|
||||||
|
assert_eq!(payee_narration("|bb ").unwrap().1, (None, Some("bb")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_transaction_empty() {
|
||||||
|
let directive = BaseDirective {
|
||||||
|
date: NaiveDate::from_ymd_opt(2000, 01, 01),
|
||||||
|
directive_name: "txn",
|
||||||
|
lines: vec!["payee | narration"],
|
||||||
|
};
|
||||||
|
let transaction = transaction(directive).unwrap().1;
|
||||||
|
assert_eq!(transaction.payee, Some("payee".into()));
|
||||||
|
assert_eq!(transaction.narration, Some("narration".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_posting_cost() {
|
||||||
|
assert_eq!(
|
||||||
|
posting("Account1 10 SHARE {$100}").unwrap().1,
|
||||||
|
DirectivePosting {
|
||||||
|
account: "Account1".into(),
|
||||||
|
amount: Some(DirectiveAmount {
|
||||||
|
value: dec!(10),
|
||||||
|
unit_symbol: "SHARE".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
cost: Some(DirectiveAmount {
|
||||||
|
value: dec!(100),
|
||||||
|
unit_symbol: "$".into(),
|
||||||
|
is_unit_prefix: true
|
||||||
|
}),
|
||||||
|
price: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
posting("Account1 10 SHARE {{1000 USD}}").unwrap().1,
|
||||||
|
DirectivePosting {
|
||||||
|
account: "Account1".into(),
|
||||||
|
amount: Some(DirectiveAmount {
|
||||||
|
value: dec!(10),
|
||||||
|
unit_symbol: "SHARE".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
cost: Some(DirectiveAmount {
|
||||||
|
value: dec!(100),
|
||||||
|
unit_symbol: "USD".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
price: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_posting_price() {
|
||||||
|
assert_eq!(
|
||||||
|
posting("Account1 10 SHARE @ $100").unwrap().1,
|
||||||
|
DirectivePosting {
|
||||||
|
account: "Account1".into(),
|
||||||
|
amount: Some(DirectiveAmount {
|
||||||
|
value: dec!(10),
|
||||||
|
unit_symbol: "SHARE".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
cost: None,
|
||||||
|
price: Some(DirectiveAmount {
|
||||||
|
value: dec!(100),
|
||||||
|
unit_symbol: "$".into(),
|
||||||
|
is_unit_prefix: true
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
posting("Account1 10 SHARE @@ 1000 USD").unwrap().1,
|
||||||
|
DirectivePosting {
|
||||||
|
account: "Account1".into(),
|
||||||
|
amount: Some(DirectiveAmount {
|
||||||
|
value: dec!(10),
|
||||||
|
unit_symbol: "SHARE".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
cost: None,
|
||||||
|
price: Some(DirectiveAmount {
|
||||||
|
value: dec!(100),
|
||||||
|
unit_symbol: "USD".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_posting_cost_and_price() {
|
||||||
|
assert_eq!(
|
||||||
|
posting("Account1 10 SHARE {$100} @ $110").unwrap().1,
|
||||||
|
DirectivePosting {
|
||||||
|
account: "Account1".into(),
|
||||||
|
amount: Some(DirectiveAmount {
|
||||||
|
value: dec!(10),
|
||||||
|
unit_symbol: "SHARE".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
cost: Some(DirectiveAmount {
|
||||||
|
value: dec!(100),
|
||||||
|
unit_symbol: "$".into(),
|
||||||
|
is_unit_prefix: true
|
||||||
|
}),
|
||||||
|
price: Some(DirectiveAmount {
|
||||||
|
value: dec!(110),
|
||||||
|
unit_symbol: "$".into(),
|
||||||
|
is_unit_prefix: true
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
posting("Account1 10 SHARE {{1000 USD}} @@ 1100 USD")
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
DirectivePosting {
|
||||||
|
account: "Account1".into(),
|
||||||
|
amount: Some(DirectiveAmount {
|
||||||
|
value: dec!(10),
|
||||||
|
unit_symbol: "SHARE".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
cost: Some(DirectiveAmount {
|
||||||
|
value: dec!(100),
|
||||||
|
unit_symbol: "USD".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
price: Some(DirectiveAmount {
|
||||||
|
value: dec!(110),
|
||||||
|
unit_symbol: "USD".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
posting("Account1 10 SHARE {{1000 USD}} @ 110 USD")
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
DirectivePosting {
|
||||||
|
account: "Account1".into(),
|
||||||
|
amount: Some(DirectiveAmount {
|
||||||
|
value: dec!(10),
|
||||||
|
unit_symbol: "SHARE".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
cost: Some(DirectiveAmount {
|
||||||
|
value: dec!(100),
|
||||||
|
unit_symbol: "USD".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
price: Some(DirectiveAmount {
|
||||||
|
value: dec!(110),
|
||||||
|
unit_symbol: "USD".into(),
|
||||||
|
is_unit_prefix: false
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_transaction_postings() {
|
||||||
|
let directive = BaseDirective {
|
||||||
|
date: NaiveDate::from_ymd_opt(2000, 01, 01),
|
||||||
|
directive_name: "txn",
|
||||||
|
lines: vec![
|
||||||
|
"payee | narration",
|
||||||
|
"Account1:Account2 $10.01",
|
||||||
|
"Account3",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let transaction = transaction(directive).unwrap().1;
|
||||||
|
assert_eq!(transaction.postings.len(), 2);
|
||||||
|
assert_eq!(transaction.postings[0].account, "Account1:Account2");
|
||||||
|
assert_eq!(
|
||||||
|
transaction.postings[0].amount,
|
||||||
|
Some(DirectiveAmount {
|
||||||
|
value: dec!(10.01),
|
||||||
|
unit_symbol: "$".into(),
|
||||||
|
is_unit_prefix: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(transaction.postings[1].account, "Account3");
|
||||||
|
assert_eq!(transaction.postings[1].amount, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/lib.rs
Normal file
119
src/lib.rs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// pub struct Asset {
|
||||||
|
// name: String,
|
||||||
|
// smallest_fraction: Option<f32>,
|
||||||
|
// asset_type: AssetType,
|
||||||
|
// // namespace?
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub enum AssetType {
|
||||||
|
// Currency(Currency),
|
||||||
|
// Security(Security),
|
||||||
|
// Commodity(Commodity),
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub struct Currency {
|
||||||
|
// // TODO
|
||||||
|
// code: String,
|
||||||
|
// symbol: Option<String>,
|
||||||
|
// smallest_fraction: Option<f32>,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub struct Commodity {
|
||||||
|
// symbol: String,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub struct Security {
|
||||||
|
// symbol: String,
|
||||||
|
// code: Option<String>
|
||||||
|
// }
|
||||||
|
|
||||||
|
// struct Asset {
|
||||||
|
// name: Option<String>,
|
||||||
|
// symbol: String,
|
||||||
|
// short_symbol: Option<String>,
|
||||||
|
// smallest_fraction: Option<f32>,
|
||||||
|
// // namespace?
|
||||||
|
// }
|
||||||
|
|
||||||
|
// struct Quantity {}
|
||||||
|
|
||||||
|
pub mod core;
|
||||||
|
pub mod queries;
|
||||||
|
// pub mod parser;
|
||||||
|
// pub mod create_ledger;
|
||||||
|
pub mod document;
|
||||||
|
pub mod output;
|
||||||
|
|
||||||
|
// pub struct Account {
|
||||||
|
// // TODO
|
||||||
|
// // type?
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub fn add(left: usize, right: usize) -> usize {
|
||||||
|
// left + right
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub type Metadata = HashMap<String, String>; // TODO
|
||||||
|
|
||||||
|
// pub struct CommoditySymbol {
|
||||||
|
// symbol: String,
|
||||||
|
// is_prefix: bool,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub struct Commodity {
|
||||||
|
// symbols: Vec<CommoditySymbol>,
|
||||||
|
// smallest_fraction: Option<f32>,
|
||||||
|
// // date?
|
||||||
|
// meta: Metadata,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub struct Amount {
|
||||||
|
// // TODO
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub struct Cost {
|
||||||
|
// number: f32,
|
||||||
|
// currency: String,
|
||||||
|
// date: u32, // TODO
|
||||||
|
// label: Option<String>,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub struct Flag {
|
||||||
|
// // TODO
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub struct Posting {
|
||||||
|
// account: String, // code?
|
||||||
|
// value: Amount,
|
||||||
|
// cost: Cost,
|
||||||
|
// is_cost_total: bool,
|
||||||
|
// price: Amount,
|
||||||
|
// flag: Option<Flag>,
|
||||||
|
// meta: Metadata,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub struct Transaction {
|
||||||
|
// date: u32, // TODO
|
||||||
|
// flag: Flag,
|
||||||
|
// payee: Option<String>,
|
||||||
|
// narration: Option<String>,
|
||||||
|
// // tags/links?
|
||||||
|
// postings: Vec<Posting>,
|
||||||
|
// meta: Metadata,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[cfg(test)]
|
||||||
|
// mod tests {
|
||||||
|
// use super::*;
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn it_works() {
|
||||||
|
// let result = add(2, 2);
|
||||||
|
// assert_eq!(result, 4);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// fn test() {
|
||||||
|
|
||||||
|
// // let a = parse_directives("abc");
|
||||||
|
// }
|
||||||
130
src/main.rs
Normal file
130
src/main.rs
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
use std::{
|
||||||
|
io::{self, Write},
|
||||||
|
path::Path,
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
use accounting_rust::{
|
||||||
|
document::Document,
|
||||||
|
output::cli::{format_balance, tui_to_ansi::text_to_ansi},
|
||||||
|
queries::{self, Query},
|
||||||
|
};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ratatui::{
|
||||||
|
crossterm::{self, style::PrintStyledContent},
|
||||||
|
layout::Rect,
|
||||||
|
prelude::CrosstermBackend,
|
||||||
|
style::{Color, Style, Stylize},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::Widget,
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
|
||||||
|
// use accounting_rust::{create_ledger::create_ledger, document::{SingleDocument, WholeDocument}, parser::parse_directives};
|
||||||
|
|
||||||
|
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// let file_data = fs::read_to_string("data/2020-bofa-checking.ledger").unwrap();
|
||||||
|
// let file_data = fs::read_to_string("data/2020-fidelity-401k.ledger").unwrap();
|
||||||
|
// let directives = parse_directives(file_data.as_str()).unwrap();
|
||||||
|
// println!("{:?}", directives.balances);
|
||||||
|
|
||||||
|
// let document = Document::new(Path::new("data/2020-fidelity-401k.ledger")).unwrap();
|
||||||
|
|
||||||
|
// let f = Frame::
|
||||||
|
|
||||||
|
// println!(
|
||||||
|
// "{}{}",
|
||||||
|
// span_to_ansi(&Span::raw("Hello ")),
|
||||||
|
// span_to_ansi(&Span::styled(
|
||||||
|
// "World",
|
||||||
|
// Style::new().fg(Color::Green).bg(Color::White)
|
||||||
|
// ))
|
||||||
|
// );
|
||||||
|
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::raw("Hello "),
|
||||||
|
Span::styled("Hello ", Style::new().fg(Color::Rgb(100, 200, 150))),
|
||||||
|
Span::styled("World", Style::new().fg(Color::Green).bg(Color::White)),
|
||||||
|
])
|
||||||
|
.centered();
|
||||||
|
let text = Text::from(line);
|
||||||
|
|
||||||
|
println!("{}", text_to_ansi(&text));
|
||||||
|
|
||||||
|
// println!("{:?}", line.to_string());
|
||||||
|
|
||||||
|
// terminal.dra
|
||||||
|
|
||||||
|
// crossterm::terminal::enable_raw_mode()?;
|
||||||
|
|
||||||
|
terminal.draw(|f| {
|
||||||
|
let area = f.area();
|
||||||
|
f.render_widget(text, area);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// PrintStyledContent
|
||||||
|
|
||||||
|
// let styled_text = Text::from(vec![
|
||||||
|
// Line::from(vec![
|
||||||
|
// Span::styled("Hello, ", Style::default().fg(Color::Green)),
|
||||||
|
// Span::styled("world! ", Style::default().fg(Color::Blue)),
|
||||||
|
// ]),
|
||||||
|
// Line::from(vec![
|
||||||
|
// Span::styled("This is ", Style::default().fg(Color::Yellow)),
|
||||||
|
// Span::styled("styled text", Style::default().fg(Color::Red)),
|
||||||
|
// Span::raw("."),
|
||||||
|
// ]),
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// // Convert the styled text to an ANSI-escaped string
|
||||||
|
// let ansi_string = styled_text.to_string();
|
||||||
|
|
||||||
|
// // Print the ANSI-styled string
|
||||||
|
// println!("{}", ansi_string);
|
||||||
|
|
||||||
|
// println!("{}", text.render(area, buf););
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
|
||||||
|
// let document = Document::new(Path::new("data/full/main.ledger")).unwrap();
|
||||||
|
|
||||||
|
// let ledger = document.generate_ledger().unwrap();
|
||||||
|
|
||||||
|
// let balance = queries::balance(
|
||||||
|
// &ledger,
|
||||||
|
// &[],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// format_balance(&ledger, &balance);
|
||||||
|
|
||||||
|
// return;
|
||||||
|
|
||||||
|
// for (account_id, amounts) in balance {
|
||||||
|
// let account = ledger.get_account(account_id).unwrap();
|
||||||
|
// if !account.get_name().starts_with("Assets") {
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
// print!("{} : ", account.get_name());
|
||||||
|
// for (i, amount) in amounts.iter().enumerate() {
|
||||||
|
// if i != 0 {
|
||||||
|
// print!(", ");
|
||||||
|
// }
|
||||||
|
// print!("{}", ledger.format_amount(amount));
|
||||||
|
// // let unit = ledger.get_unit(amount.unit_id).unwrap();
|
||||||
|
// // let symbol = unit.default_symbol();
|
||||||
|
// // if symbol.is_prefix {
|
||||||
|
// // print!("{}{}", symbol.symbol, amount.value);
|
||||||
|
// // } else {
|
||||||
|
// // print!("{} {}", amount.value, symbol.symbol);
|
||||||
|
// // }
|
||||||
|
// }
|
||||||
|
// println!();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let ledger = create_ledger(&file_data).unwrap();
|
||||||
|
// println!("{:?}", val);
|
||||||
|
}
|
||||||
19
src/output/amounts.rs
Normal file
19
src/output/amounts.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
use crate::core::{Amount, Ledger};
|
||||||
|
|
||||||
|
impl Ledger {
|
||||||
|
pub fn format_amount(&self, amount: &Amount) -> String {
|
||||||
|
let unit = self.get_unit(amount.unit_id).unwrap();
|
||||||
|
let default_symbol = unit.default_symbol();
|
||||||
|
let amount = self.round_amount(&amount);
|
||||||
|
|
||||||
|
if default_symbol.is_prefix {
|
||||||
|
format!("{}{}", default_symbol.symbol, amount.value)
|
||||||
|
} else {
|
||||||
|
if default_symbol.symbol.len() == 1 {
|
||||||
|
format!("{}{}", amount.value, default_symbol.symbol)
|
||||||
|
} else {
|
||||||
|
format!("{} {}", amount.value, default_symbol.symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
src/output/cli/balance.rs
Normal file
277
src/output/cli/balance.rs
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
use std::{
|
||||||
|
cmp::{max, Ordering},
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::core::{combine_amounts, Account, Amount, Ledger};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct BalanceTree {
|
||||||
|
name: String,
|
||||||
|
children: Vec<BalanceTree>,
|
||||||
|
amounts: Option<Vec<Amount>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountInfo<'a> {
|
||||||
|
account_path: Vec<&'a str>,
|
||||||
|
amounts: Vec<Amount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn construct_tree(ledger: &Ledger, account_balances: &HashMap<u32, Vec<Amount>>) -> BalanceTree {
|
||||||
|
let account_info: Vec<AccountInfo> = account_balances
|
||||||
|
.iter()
|
||||||
|
.map(|(&account_id, amounts)| {
|
||||||
|
let account = ledger.get_account(account_id).unwrap();
|
||||||
|
AccountInfo {
|
||||||
|
account_path: account.split_name_groups(),
|
||||||
|
amounts: amounts.clone(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let root_children = construct_tree_children(account_info.iter().collect(), 0);
|
||||||
|
|
||||||
|
BalanceTree {
|
||||||
|
name: "(Total)".into(),
|
||||||
|
children: root_children,
|
||||||
|
amounts: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fn construct_subtree(accounts: Vec<AccountInfo>, level: usize, parent: &BalanceTree) {
|
||||||
|
// for account in accounts
|
||||||
|
// .iter()
|
||||||
|
// .filter(|v| v.account_path[level] == parent.name)
|
||||||
|
// {}
|
||||||
|
// }
|
||||||
|
|
||||||
|
fn construct_tree_children(accounts: Vec<&AccountInfo>, level: usize) -> Vec<BalanceTree> {
|
||||||
|
let mut sub_trees = Vec::new();
|
||||||
|
|
||||||
|
let names = HashSet::<&str>::from_iter(accounts.iter().map(|v| v.account_path[level]));
|
||||||
|
|
||||||
|
for name in names {
|
||||||
|
let leaf_account = accounts
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.account_path.len() - 1 == level && v.account_path[level] == name);
|
||||||
|
let sub_accounts: Vec<&AccountInfo> = accounts
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|v| v.account_path.len() - 1 != level && v.account_path[level] == name)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut sub_tree = BalanceTree {
|
||||||
|
name: name.into(),
|
||||||
|
children: Vec::new(),
|
||||||
|
amounts: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if sub_accounts.len() != 0 {
|
||||||
|
sub_tree.children = construct_tree_children(sub_accounts, level + 1);
|
||||||
|
if let Some(leaf_account) = leaf_account {
|
||||||
|
sub_tree.children.push(BalanceTree {
|
||||||
|
name: "...".into(),
|
||||||
|
children: Vec::new(),
|
||||||
|
amounts: Some(leaf_account.amounts.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if let Some(leaf_account) = leaf_account {
|
||||||
|
sub_tree.amounts = Some(leaf_account.amounts.clone());
|
||||||
|
} else {
|
||||||
|
panic!("No account");
|
||||||
|
}
|
||||||
|
|
||||||
|
sub_trees.push(sub_tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub_trees
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_tree_totals(tree: &mut BalanceTree) {
|
||||||
|
if tree.amounts.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for child in &mut tree.children {
|
||||||
|
set_tree_totals(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_amounts = combine_amounts(
|
||||||
|
tree.children
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.amounts.clone().unwrap().into_iter())
|
||||||
|
.flatten(),
|
||||||
|
);
|
||||||
|
tree.amounts = Some(total_amounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_tree(tree: &BalanceTree, ledger: &Ledger, level: usize, amount_pos: usize) {
|
||||||
|
let relative_amount_pos = amount_pos - (level*2 + tree.name.len());
|
||||||
|
let main_line = format!("{}{} {}", " ".repeat(level), tree.name, "─".repeat(relative_amount_pos));
|
||||||
|
let tree_amounts = tree.amounts.as_ref().unwrap().iter().filter(|v| !ledger.round_amount(v).value.is_zero());
|
||||||
|
let tree_amounts_count = tree_amounts.clone().count();
|
||||||
|
for (i, amount) in tree_amounts.enumerate() {
|
||||||
|
let mut line = String::new();
|
||||||
|
if i == 0 {
|
||||||
|
line += &main_line;
|
||||||
|
if tree_amounts_count > 1 {
|
||||||
|
line += "┬"
|
||||||
|
} else {
|
||||||
|
line += "─"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line += &" ".repeat(amount_pos);
|
||||||
|
if i == tree_amounts_count - 1 {
|
||||||
|
line += " └"
|
||||||
|
} else {
|
||||||
|
line += " │"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line += &format!(" {}", ledger.format_amount(amount));
|
||||||
|
|
||||||
|
println!("{}", line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// println!("{}{} {} {:?}", " ".repeat(level), tree.name, "-".repeat(relative_amount_pos), tree.amounts);
|
||||||
|
let mut children: Vec<&BalanceTree> = tree.children.iter().collect();
|
||||||
|
children.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
for child in children {
|
||||||
|
print_tree(&child, ledger, level + 1, amount_pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_max_account_len(tree: &BalanceTree, indent_amount: usize, indent_level: usize) -> usize {
|
||||||
|
let current_len = tree.name.len() + indent_amount * indent_level;
|
||||||
|
|
||||||
|
let mut max_length = current_len;
|
||||||
|
for child in &tree.children {
|
||||||
|
let child_max_len = calculate_max_account_len(child, indent_amount, indent_level+1);
|
||||||
|
max_length = max(max_length, child_max_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
max_length
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_balance(ledger: &Ledger, account_balances: &HashMap<u32, Vec<Amount>>) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
let mut tree = construct_tree(ledger, account_balances);
|
||||||
|
set_tree_totals(&mut tree);
|
||||||
|
|
||||||
|
let max_account_len = calculate_max_account_len(&tree, 2, 0);
|
||||||
|
|
||||||
|
println!("{}", max_account_len);
|
||||||
|
print_tree(&tree, &ledger, 0, max_account_len + 5);
|
||||||
|
// println!("{:?}", tree);
|
||||||
|
|
||||||
|
// let base_account_info: Vec<AccountInfo> = account_balances
|
||||||
|
// .iter()
|
||||||
|
// .map(|(&account_id, amounts)| {
|
||||||
|
// let account = ledger.get_account(account_id).unwrap();
|
||||||
|
// AccountInfo {
|
||||||
|
// account_path: account.split_name_groups(),
|
||||||
|
// is_group: false,
|
||||||
|
// amounts: amounts.clone(),
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// .collect();
|
||||||
|
|
||||||
|
// let mut account_info: Vec<AccountInfo> = Vec::new();
|
||||||
|
|
||||||
|
// for base_account in &base_account_info {}
|
||||||
|
|
||||||
|
// let mut group_account_info = Vec::new();
|
||||||
|
// for account in &account_info {
|
||||||
|
// let mut account_path = account.account_path.clone();
|
||||||
|
// loop {
|
||||||
|
// account_path.pop();
|
||||||
|
// if account_path.len() == 0 {
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if !group_account_info
|
||||||
|
// .iter()
|
||||||
|
// .any(|v: &AccountInfo| v.account_path == account_path)
|
||||||
|
// {
|
||||||
|
// let amount_totals = combine_amounts(
|
||||||
|
// account_info
|
||||||
|
// .iter()
|
||||||
|
// .filter(|v| v.account_path.starts_with(&account_path))
|
||||||
|
// .map(|v| v.amounts.iter())
|
||||||
|
// .flatten()
|
||||||
|
// .map(|v| *v),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// group_account_info.push(AccountInfo {
|
||||||
|
// account_path: account_path.clone(),
|
||||||
|
// is_group: true,
|
||||||
|
// amounts: amount_totals,
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// account_info.extend(group_account_info.into_iter());
|
||||||
|
|
||||||
|
// sort_account_info(&mut account_info);
|
||||||
|
|
||||||
|
// for i in account_info {
|
||||||
|
// println!("{} {:?}", i.account_path.join(":"), i.amounts);
|
||||||
|
// }
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
// fn sort_account_info(account_info: &mut Vec<AccountInfo>) {
|
||||||
|
// account_info.sort_by(|a, b| {
|
||||||
|
// let ordering = a
|
||||||
|
// .account_path
|
||||||
|
// .iter()
|
||||||
|
// .zip(b.account_path.iter())
|
||||||
|
// .map(|(&a, &b)| a.cmp(b))
|
||||||
|
// .find(|val| *val != Ordering::Equal);
|
||||||
|
|
||||||
|
// ordering.unwrap_or_else(|| a.account_path.len().cmp(&b.account_path.len()))
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /////////////
|
||||||
|
// // Tests //
|
||||||
|
// /////////////
|
||||||
|
|
||||||
|
// #[cfg(test)]
|
||||||
|
// mod tests {
|
||||||
|
// use super::*;
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn test_sort_account_info() {
|
||||||
|
// let mut account_info = vec![
|
||||||
|
// AccountInfo {
|
||||||
|
// account_path: vec!["a2", "b1"],
|
||||||
|
// is_group: false,
|
||||||
|
// amounts: vec![],
|
||||||
|
// },
|
||||||
|
// AccountInfo {
|
||||||
|
// account_path: vec!["a1", "b2"],
|
||||||
|
// is_group: false,
|
||||||
|
// amounts: vec![],
|
||||||
|
// },
|
||||||
|
// AccountInfo {
|
||||||
|
// account_path: vec!["a1", "b1"],
|
||||||
|
// is_group: false,
|
||||||
|
// amounts: vec![],
|
||||||
|
// },
|
||||||
|
// AccountInfo {
|
||||||
|
// account_path: vec!["a1"],
|
||||||
|
// is_group: false,
|
||||||
|
// amounts: vec![],
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
// sort_account_info(&mut account_info);
|
||||||
|
|
||||||
|
// println!("{:?}", account_info[0].account_path);
|
||||||
|
// println!("{:?}", account_info[1].account_path);
|
||||||
|
// println!("{:?}", account_info[2].account_path);
|
||||||
|
// println!("{:?}", account_info[3].account_path);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
4
src/output/cli/mod.rs
Normal file
4
src/output/cli/mod.rs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
mod balance;
|
||||||
|
pub mod tui_to_ansi;
|
||||||
|
|
||||||
|
pub use balance::*;
|
||||||
94
src/output/cli/tui_to_ansi.rs
Normal file
94
src/output/cli/tui_to_ansi.rs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
use ratatui::{
|
||||||
|
style::{Color, Modifier},
|
||||||
|
text::{Span, Text},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn span_to_ansi(span: &Span) -> String {
|
||||||
|
let mut styled_string = String::new();
|
||||||
|
|
||||||
|
if let Some(color) = span.style.fg {
|
||||||
|
if let Color::Rgb(r, g, b) = color {
|
||||||
|
styled_string.push_str(&format!("\x1b[38;2;{};{};{}m", r, g, b));
|
||||||
|
} else {
|
||||||
|
styled_string.push_str(&format!(
|
||||||
|
"\x1b[{}m",
|
||||||
|
match color {
|
||||||
|
Color::Black => "30",
|
||||||
|
Color::Red => "31",
|
||||||
|
Color::Green => "32",
|
||||||
|
Color::Yellow => "33",
|
||||||
|
Color::Blue => "34",
|
||||||
|
Color::Magenta => "35",
|
||||||
|
Color::Cyan => "36",
|
||||||
|
Color::Gray => "37",
|
||||||
|
Color::DarkGray => "90",
|
||||||
|
Color::LightRed => "91",
|
||||||
|
Color::LightGreen => "92",
|
||||||
|
Color::LightYellow => "93",
|
||||||
|
Color::LightBlue => "94",
|
||||||
|
Color::LightMagenta => "95",
|
||||||
|
Color::LightCyan => "96",
|
||||||
|
Color::White => "97",
|
||||||
|
_ => "39",
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(color) = span.style.bg {
|
||||||
|
if let Color::Rgb(r, g, b) = color {
|
||||||
|
styled_string.push_str(&format!("\x1b[48;2;{};{};{}m", r, g, b));
|
||||||
|
} else {
|
||||||
|
styled_string.push_str(&format!(
|
||||||
|
"\x1b[{}m",
|
||||||
|
match color {
|
||||||
|
Color::Black => "40",
|
||||||
|
Color::Red => "41",
|
||||||
|
Color::Green => "42",
|
||||||
|
Color::Yellow => "43",
|
||||||
|
Color::Blue => "44",
|
||||||
|
Color::Magenta => "45",
|
||||||
|
Color::Cyan => "46",
|
||||||
|
Color::Gray => "47",
|
||||||
|
Color::DarkGray => "100",
|
||||||
|
Color::LightRed => "101",
|
||||||
|
Color::LightGreen => "102",
|
||||||
|
Color::LightYellow => "103",
|
||||||
|
Color::LightBlue => "104",
|
||||||
|
Color::LightMagenta => "105",
|
||||||
|
Color::LightCyan => "106",
|
||||||
|
Color::White => "107",
|
||||||
|
_ => "49",
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if span.style.add_modifier.contains(Modifier::BOLD) {
|
||||||
|
styled_string.push_str("\x1b[1m");
|
||||||
|
}
|
||||||
|
if span.style.add_modifier.contains(Modifier::ITALIC) {
|
||||||
|
styled_string.push_str("\x1b[3m");
|
||||||
|
}
|
||||||
|
if span.style.add_modifier.contains(Modifier::UNDERLINED) {
|
||||||
|
styled_string.push_str("\x1b[4m");
|
||||||
|
}
|
||||||
|
|
||||||
|
styled_string.push_str(&span.content);
|
||||||
|
styled_string.push_str("\x1b[0m"); // Reset style
|
||||||
|
styled_string
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text_to_ansi(text: &Text) -> String {
|
||||||
|
text.lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| {
|
||||||
|
line.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span_to_ansi(span))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/n")
|
||||||
|
}
|
||||||
2
src/output/mod.rs
Normal file
2
src/output/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod cli;
|
||||||
|
mod amounts;
|
||||||
44
src/queries/balance.rs
Normal file
44
src/queries/balance.rs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::core::{Amount, Ledger};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
pub enum Query {
|
||||||
|
StartDate(NaiveDate),
|
||||||
|
EndDate(NaiveDate),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn balance(ledger: &Ledger, query: &[Query]) -> HashMap<u32, Vec<Amount>> {
|
||||||
|
let relevant_transactions = ledger.get_transactions().iter().filter(|txn| {
|
||||||
|
query.iter().all(|q| match q {
|
||||||
|
Query::StartDate(date) => txn.get_date() >= *date,
|
||||||
|
Query::EndDate(date) => txn.get_date() <= *date,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut accounts = HashMap::new();
|
||||||
|
|
||||||
|
for txn in relevant_transactions.clone() {
|
||||||
|
for posting in txn.get_postings() {
|
||||||
|
let amount = posting.get_amount();
|
||||||
|
let account_vals = accounts
|
||||||
|
.entry(posting.get_account_id())
|
||||||
|
.or_insert(HashMap::new());
|
||||||
|
let a = account_vals.entry(amount.unit_id).or_insert(dec!(0));
|
||||||
|
*a += amount.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts
|
||||||
|
.iter()
|
||||||
|
.map(|(&k, v)| {
|
||||||
|
(
|
||||||
|
k,
|
||||||
|
v.into_iter()
|
||||||
|
.map(|(&unit_id, &value)| Amount { value, unit_id })
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
3
src/queries/mod.rs
Normal file
3
src/queries/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mod balance;
|
||||||
|
|
||||||
|
pub use balance::*;
|
||||||
Loading…
Reference in New Issue
Block a user