├── .github └── workflows │ └── go.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE.txt ├── Makefile ├── README.md ├── balances.go ├── balances_test.go ├── date.go ├── date_test.go ├── decimal ├── decimal.go └── decimal_test.go ├── go.mod ├── go.sum ├── include_test.go ├── ledger ├── book │ ├── book.toml │ ├── genbook.bash │ ├── genperf.bash │ └── src │ │ ├── 01_01_Installation.md │ │ ├── 01_02_FileFormat.md │ │ ├── 01_03_RunningLedger.md │ │ ├── 02_Accounts.md │ │ ├── 02_Balance.md │ │ ├── 02_Equity.md │ │ ├── 02_Export.md │ │ ├── 02_Import.md │ │ ├── 02_Print.md │ │ ├── 02_Register.md │ │ ├── 02_Stats.md │ │ ├── Editing_VimPlugin.md │ │ ├── Example.md │ │ ├── Introduction.md │ │ ├── LICENSE.md │ │ ├── Performance.md │ │ ├── SUMMARY.md │ │ ├── Web_Accounts.md │ │ ├── Web_AddTransaction.md │ │ ├── Web_GeneralLedger.md │ │ ├── Web_Overview.md │ │ ├── Web_Portfolio.md │ │ ├── Web_Quickview.md │ │ ├── Web_Reports.md │ │ ├── consoleshots │ │ ├── vimfold.png │ │ └── vimsyn.png │ │ ├── ledger.dat │ │ ├── portfolio-stocks.toml │ │ ├── portfolio.toml │ │ ├── quickview.toml │ │ ├── reports.toml │ │ ├── transactions.csv │ │ └── webshots │ │ ├── account.png │ │ ├── accounts.png │ │ ├── addtrans.png │ │ ├── general-ledger.png │ │ ├── portfolio-crypto.png │ │ ├── quickview.png │ │ ├── report-expenses.png │ │ ├── report-networth.png │ │ └── report-savings.png ├── cmd │ ├── export.go │ ├── financialQuotes.go │ ├── import.go │ ├── internal │ │ ├── httpcompress │ │ │ └── compress.go │ │ └── pdr │ │ │ ├── grammar.peg │ │ │ ├── grammar.peg.go │ │ │ ├── pdr.go │ │ │ └── pdr_test.go │ ├── lint.go │ ├── print.go │ ├── printAccounts.go │ ├── printBalance.go │ ├── printEquity.go │ ├── printRegister.go │ ├── root.go │ ├── static │ │ ├── bootstrap-5.3.2.bundle.min.js │ │ ├── bootstrap-5.3.2.min.css │ │ ├── chart-4.4.0.umd.js │ │ ├── datatables-1.13.4.min.css │ │ ├── datatables-1.13.4.min.js │ │ ├── daterangepicker.css │ │ ├── daterangepicker.js │ │ ├── dropdown.css │ │ ├── favicon.ico │ │ ├── jquery-3.7.1.min.js │ │ └── moment.min.js │ ├── stats.go │ ├── templates │ │ ├── template.account.html │ │ ├── template.accounts.html │ │ ├── template.addtransaction.html │ │ ├── template.barlinechart.html │ │ ├── template.common.html │ │ ├── template.leaderboardchart.html │ │ ├── template.ledger.html │ │ ├── template.piechart.html │ │ ├── template.portfolio.html │ │ └── template.quickview.html │ ├── version.go │ ├── web-portfolio-sample.toml │ ├── web-quickview-sample.toml │ ├── web-reports-sample.toml │ ├── web.go │ ├── webConfig.go │ ├── webHandlerAccounts.go │ ├── webHandlerLedger.go │ ├── webHandlerPortfolio.go │ ├── webHandlerReport.go │ └── webLoadTemplate.go ├── genprofile.bash ├── internal │ └── fastcolor │ │ ├── LICENSE.txt │ │ └── fastcolor.go ├── main.go └── man │ ├── ledger.1 │ └── ledger.5 ├── ledgerReader.go ├── ledgerReader_test.go ├── linescanner.go ├── logo.png ├── parse.go ├── parseFuzz_test.go ├── parse_test.go ├── testdata ├── ledger-2021-05.dat ├── ledger-2022-01.dat ├── ledger-2022-02.dat ├── ledger-2022-04.dat ├── ledgerBench.dat ├── ledgerRoot.dat ├── ledgerRootGlob.dat ├── ledgerRootNonExist.dat └── ledgerRootUnbalanced.dat ├── types.go └── vim-ledger ├── ftplugin └── ledger.vim └── syntax └── ledger.vim /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: '>=1.20.0' 20 | check-latest: true 21 | 22 | - name: Build 23 | run: go build -v ./... 24 | 25 | - name: Test 26 | run: go test -v -coverprofile=profile.cov . ./decimal 27 | 28 | - uses: shogo82148/actions-goveralls@v1 29 | with: 30 | path-to-profile: profile.cov 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.dat 2 | *.exe 3 | bin 4 | pkg 5 | dist 6 | html-book 7 | src-book 8 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # .goreleaser.yml 2 | before: 3 | hooks: 4 | - make docs 5 | builds: 6 | - 7 | env: 8 | - CGO_ENABLED=0 9 | id: "ledger" 10 | main: ./ledger/. 11 | binary: ledger 12 | ldflags: 13 | - -s -w -X github.com/howeyc/ledger/ledger/cmd.version={{.Version}} 14 | goos: 15 | - windows 16 | - darwin 17 | - linux 18 | - freebsd 19 | - openbsd 20 | goarch: 21 | - amd64 22 | - arm64 23 | archives: 24 | - 25 | builds: 26 | - ledger 27 | files: 28 | - docs/* 29 | - LICENSE.txt 30 | wrap_in_directory: true 31 | format_overrides: 32 | - goos: windows 33 | format: zip 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Chris Howey 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs clean release snapshot 2 | 3 | docs: 4 | mkdir -p docs 5 | mandoc -Tpdf -l ledger/man/ledger.1 > docs/ledger.1.pdf 6 | mandoc -Tpdf -l ledger/man/ledger.5 > docs/ledger.5.pdf 7 | cp ledger/man/ledger.1 docs/ 8 | cp ledger/man/ledger.5 docs/ 9 | 10 | snapshot: 11 | goreleaser --skip-publish --rm-dist --snapshot 12 | 13 | release: 14 | goreleaser 15 | 16 | clean: 17 | rm -rf docs dist 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![license](https://img.shields.io/badge/license-ISC-brightgreen.svg)](https://en.wikipedia.org/wiki/ISC_license) 2 | [![GitHub releases](https://img.shields.io/github/tag/howeyc/ledger.svg)](https://github.com/howeyc/ledger/releases) 3 | [![GitHub downloads](https://img.shields.io/github/downloads/howeyc/ledger/total.svg?logo=github&logoColor=lime)](https://github.com/howeyc/ledger/releases) 4 | [![Chat on Libera](https://img.shields.io/badge/chat-libera-blue.svg)](https://matrix.to/#/#plaintextaccounting:libera.chat) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/howeyc/ledger)](https://goreportcard.com/report/github.com/howeyc/ledger) 6 | [![Go Reference](https://pkg.go.dev/badge/github.com/howeyc/ledger.svg)](https://pkg.go.dev/github.com/howeyc/ledger) 7 | [![Coverage Status](https://coveralls.io/repos/github/howeyc/ledger/badge.svg?branch=master)](https://coveralls.io/github/howeyc/ledger?branch=master) 8 | 9 |
10 | ledger-logo 11 |
12 | 13 | ## Purpose 14 | 15 | Ledger is a command line application for plain text accounting. Providing 16 | commands to view balances, register of transactions, importing of CSV files, 17 | exporting of CSV files, and a web interface to view reports, and track 18 | investments. 19 | 20 | ## Documentation 21 | 22 | Head over to https://howeyc.github.io/ledger/ 23 | -------------------------------------------------------------------------------- /balances.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import ( 4 | "slices" 5 | "strings" 6 | 7 | "github.com/howeyc/ledger/decimal" 8 | ) 9 | 10 | // GetBalances provided a list of transactions and filter strings, returns account balances of 11 | // all accounts that have any filter as a substring of the account name. Also 12 | // returns balances for each account level depth as a separate record. 13 | // 14 | // Accounts are sorted by name. 15 | func GetBalances(generalLedger []*Transaction, filterArr []string) []*Account { 16 | var accList []*Account 17 | balances := make(map[string]*Account) 18 | 19 | // at every depth, for each account, track the parent account 20 | depthMap := make(map[int]map[string]string) 21 | var maxDepth int 22 | 23 | incAccount := func(accName string, val decimal.Decimal) { 24 | // track parent 25 | accDepth := strings.Count(accName, ":") + 1 26 | pmap, pmapfound := depthMap[accDepth] 27 | if !pmapfound { 28 | pmap = make(map[string]string) 29 | depthMap[accDepth] = pmap 30 | } 31 | if _, foundparent := pmap[accName]; !foundparent && accDepth > 1 { 32 | colIdx := strings.LastIndex(accName, ":") 33 | pmap[accName] = accName[:colIdx] 34 | maxDepth = max(maxDepth, accDepth) 35 | } 36 | 37 | // add to balance 38 | if acc, ok := balances[accName]; !ok { 39 | acc := &Account{Name: accName, Balance: val} 40 | accList = append(accList, acc) 41 | balances[accName] = acc 42 | } else { 43 | acc.Balance = acc.Balance.Add(val) 44 | } 45 | } 46 | 47 | for _, trans := range generalLedger { 48 | for _, accChange := range trans.AccountChanges { 49 | inFilter := len(filterArr) == 0 50 | for i := 0; i < len(filterArr) && !inFilter; i++ { 51 | if strings.Contains(accChange.Name, filterArr[i]) { 52 | inFilter = true 53 | } 54 | } 55 | if inFilter { 56 | incAccount(accChange.Name, accChange.Balance) 57 | } 58 | } 59 | } 60 | 61 | // roll-up balances 62 | for curDepth := maxDepth; curDepth > 1; curDepth-- { 63 | for accName, parentName := range depthMap[curDepth] { 64 | incAccount(parentName, balances[accName].Balance) 65 | } 66 | } 67 | 68 | slices.SortFunc(accList, func(a, b *Account) int { 69 | return strings.Compare(a.Name, b.Name) 70 | }) 71 | return accList 72 | } 73 | -------------------------------------------------------------------------------- /balances_test.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "math/rand" 8 | "testing" 9 | "time" 10 | 11 | "github.com/howeyc/ledger/decimal" 12 | ) 13 | 14 | type testBalCase struct { 15 | name string 16 | data string 17 | balances []Account 18 | err error 19 | } 20 | 21 | var testBalCases = []testBalCase{ 22 | { 23 | "simple case", 24 | `1970/01/01 Payee 25 | Expense/test (123 * 3) 26 | Assets 27 | 28 | 1970/01/01 Payee 29 | Expense/test 123 30 | Assets 31 | `, 32 | []Account{ 33 | { 34 | Name: "Assets", 35 | Balance: decimal.NewFromFloat(-4 * 123), 36 | }, 37 | { 38 | Name: "Expense/test", 39 | Balance: decimal.NewFromFloat(123 + 369), 40 | }, 41 | }, 42 | nil, 43 | }, 44 | { 45 | "heirarchy", 46 | `1970/01/01 Payee 47 | Expense:test (123 * 3) 48 | Assets 49 | 50 | 1970/01/01 Payee 51 | Expense:foo 123 52 | Assets 53 | `, 54 | []Account{ 55 | { 56 | Name: "Assets", 57 | Balance: decimal.NewFromFloat(-4 * 123), 58 | }, 59 | { 60 | Name: "Expense", 61 | Balance: decimal.NewFromFloat(123 + 369), 62 | }, 63 | { 64 | Name: "Expense:foo", 65 | Balance: decimal.NewFromFloat(123), 66 | }, 67 | { 68 | Name: "Expense:test", 69 | Balance: decimal.NewFromFloat(369), 70 | }, 71 | }, 72 | nil, 73 | }, 74 | } 75 | 76 | func TestBalanceLedger(t *testing.T) { 77 | for _, tc := range testBalCases { 78 | b := bytes.NewBufferString(tc.data) 79 | transactions, err := ParseLedger(b) 80 | bals := GetBalances(transactions, []string{}) 81 | if (err != nil && tc.err == nil) || (err != nil && tc.err != nil && err.Error() != tc.err.Error()) { 82 | t.Errorf("Error: expected `%s`, got `%s`", tc.err, err) 83 | } 84 | exp, _ := json.Marshal(tc.balances) 85 | got, _ := json.Marshal(bals) 86 | if string(exp) != string(got) { 87 | t.Errorf("Error(%s): expected \n`%s`, \ngot \n`%s`", tc.name, exp, got) 88 | } 89 | } 90 | } 91 | 92 | func BenchmarkGetBalances(b *testing.B) { 93 | trans := make([]*Transaction, 0, 100000) 94 | for i := range 100000 { 95 | a := rand.Intn(50) 96 | b := rand.Intn(10) 97 | c := rand.Intn(5) 98 | d := rand.Intn(50) 99 | e := rand.Intn(10) 100 | f := rand.Intn(5) 101 | amt := rand.Float64() * 10000 102 | trans = append(trans, &Transaction{ 103 | Date: time.Now(), 104 | Payee: fmt.Sprintf("Trans %d", i), 105 | AccountChanges: []Account{ 106 | { 107 | Name: fmt.Sprintf("Acc%d:Acc%d:Acc%d", a, b, c), 108 | Balance: decimal.NewFromFloat(amt), 109 | }, 110 | { 111 | Name: fmt.Sprintf("Acc%d:Acc%d:Acc%d", d, e, f), 112 | Balance: decimal.NewFromFloat(-amt), 113 | }, 114 | }, 115 | }) 116 | } 117 | b.ResetTimer() 118 | for range b.N { 119 | GetBalances(trans, []string{}) 120 | } 121 | } 122 | 123 | func TestBalancesByPeriod(t *testing.T) { 124 | b := bytes.NewBufferString(` 125 | 2022/02/02 Payee 126 | Assets 50 127 | Income 128 | 129 | 2022/01/02 Payee 130 | Assets 50 131 | Income 132 | 133 | 2022/03/02 Payee 134 | Assets 50 135 | Income 136 | 137 | 2022/04/02 Payee 138 | Assets 50 139 | Income 140 | 141 | 2022/05/02 Payee 142 | Assets 50 143 | Income 144 | 145 | `) 146 | 147 | trans, _ := ParseLedger(b) 148 | partitionRb := BalancesByPeriod(trans, PeriodQuarter, RangePartition) 149 | snapshotRb := BalancesByPeriod(trans, PeriodQuarter, RangeSnapshot) 150 | 151 | if partitionRb[len(partitionRb)-1].Balances[0].Balance.Abs().Cmp(decimal.NewFromInt(100)) != 0 { 152 | t.Error("range balance by partition not accurate") 153 | } 154 | if snapshotRb[len(snapshotRb)-1].Balances[0].Balance.Abs().Cmp(decimal.NewFromInt(250)) != 0 { 155 | t.Error("range balance by snapshot not accurate") 156 | } 157 | 158 | transPeriod := TransactionsByPeriod(trans, PeriodQuarter) 159 | lastBals := GetBalances(transPeriod[len(transPeriod)-1].Transactions, []string{}) 160 | if partitionRb[len(partitionRb)-1].Balances[0].Balance.Abs().Cmp(lastBals[0].Balance.Abs()) != 0 { 161 | t.Error("range balance by partition not equal to trans by period balance") 162 | } 163 | 164 | var blanktrans []*Transaction 165 | rb := BalancesByPeriod(blanktrans, PeriodDay, RangeSnapshot) 166 | if len(rb) > 1 { 167 | t.Error("range balances for non-existent transactions") 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /date.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import "time" 4 | 5 | // TransactionsInDateRange returns a new array of transactions that are in the date range 6 | // specified by start and end. The returned list contains transactions on the same day as start 7 | // but does not include any transactions on the day of end. 8 | func TransactionsInDateRange(trans []*Transaction, start, end time.Time) []*Transaction { 9 | var newlist []*Transaction 10 | 11 | start = start.Add(-1 * time.Second) 12 | 13 | for _, tran := range trans { 14 | if tran.Date.After(start) && tran.Date.Before(end) { 15 | newlist = append(newlist, tran) 16 | } 17 | } 18 | 19 | return newlist 20 | } 21 | 22 | // Period is used to specify the length of a date range or frequency 23 | type Period string 24 | 25 | // Periods supported by ledger 26 | const ( 27 | PeriodDay Period = "Daily" 28 | PeriodWeek Period = "Weekly" 29 | Period2Week Period = "BiWeekly" 30 | PeriodMonth Period = "Monthly" 31 | Period2Month Period = "BiMonthly" 32 | PeriodQuarter Period = "Quarterly" 33 | PeriodSemiYear Period = "SemiYearly" 34 | PeriodYear Period = "Yearly" 35 | ) 36 | 37 | func getDateBoundaries(per Period, start, end time.Time) []time.Time { 38 | var incDays, incMonth, incYear int 39 | var periodStart time.Time 40 | 41 | switch per { 42 | case PeriodDay: 43 | incDays = 1 44 | periodStart = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) 45 | case PeriodWeek: 46 | incDays = 7 47 | for periodStart = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC); periodStart.Weekday() != time.Sunday; { 48 | periodStart = periodStart.AddDate(0, 0, -1) 49 | } 50 | case Period2Week: 51 | incDays = 14 52 | for periodStart = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC); periodStart.Weekday() != time.Sunday; { 53 | periodStart = periodStart.AddDate(0, 0, -1) 54 | } 55 | case PeriodMonth: 56 | incMonth = 1 57 | periodStart = time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC) 58 | case Period2Month: 59 | incMonth = 2 60 | periodStart = time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC) 61 | case PeriodQuarter: 62 | incMonth = 3 63 | switch start.Month() { 64 | case time.January, time.February, time.March: 65 | periodStart = time.Date(start.Year(), time.January, 1, 0, 0, 0, 0, time.UTC) 66 | case time.April, time.May, time.June: 67 | periodStart = time.Date(start.Year(), time.April, 1, 0, 0, 0, 0, time.UTC) 68 | case time.July, time.August, time.September: 69 | periodStart = time.Date(start.Year(), time.July, 1, 0, 0, 0, 0, time.UTC) 70 | default: 71 | periodStart = time.Date(start.Year(), time.October, 1, 0, 0, 0, 0, time.UTC) 72 | } 73 | case PeriodSemiYear: 74 | incMonth = 6 75 | switch start.Month() { 76 | case time.January, time.February, time.March, time.April, time.May, time.June: 77 | periodStart = time.Date(start.Year(), time.January, 1, 0, 0, 0, 0, time.UTC) 78 | default: 79 | periodStart = time.Date(start.Year(), time.July, 1, 0, 0, 0, 0, time.UTC) 80 | } 81 | case PeriodYear: 82 | incYear = 1 83 | periodStart = time.Date(start.Year(), time.January, 1, 0, 0, 0, 0, time.UTC) 84 | default: 85 | return []time.Time{start, end} 86 | } 87 | 88 | boundaries := []time.Time{periodStart} 89 | for periodStart.Before(end) || periodStart.Equal(end) { 90 | periodStart = periodStart.AddDate(incYear, incMonth, incDays) 91 | boundaries = append(boundaries, periodStart) 92 | } 93 | 94 | return boundaries 95 | } 96 | 97 | // RangeType is used to specify how the data is "split" into sections 98 | type RangeType string 99 | 100 | const ( 101 | // RangeSnapshot will have each section be the running total at the time of the snapshot 102 | RangeSnapshot RangeType = "Snapshot" 103 | 104 | // RangePartition will have each section be the accumulated value of the transactions within that partition's date range 105 | RangePartition RangeType = "Partition" 106 | ) 107 | 108 | // RangeTransactions contains the transactions and the start and end time of the date range 109 | type RangeTransactions struct { 110 | Start, End time.Time 111 | Transactions []*Transaction 112 | } 113 | 114 | // startEndTime will return the start and end Times of a list of transactions 115 | func startEndTime(trans []*Transaction) (start, end time.Time) { 116 | if len(trans) < 1 { 117 | return 118 | } 119 | 120 | start = trans[0].Date 121 | end = trans[0].Date 122 | 123 | for _, t := range trans { 124 | if end.Before(t.Date) { 125 | end = t.Date 126 | } 127 | if start.After(t.Date) { 128 | start = t.Date 129 | } 130 | } 131 | 132 | // to include last days' transactions in date period splits 133 | end = end.Add(time.Second) 134 | 135 | return 136 | } 137 | 138 | // TransactionsByPeriod will return the transactions for each period. 139 | func TransactionsByPeriod(trans []*Transaction, per Period) []*RangeTransactions { 140 | tStart, tEnd := startEndTime(trans) 141 | 142 | boundaries := getDateBoundaries(per, tStart, tEnd) 143 | results := make([]*RangeTransactions, 0, len(boundaries)-1) 144 | 145 | bStart := boundaries[0] 146 | for _, boundary := range boundaries[1:] { 147 | bEnd := boundary 148 | 149 | bTrans := TransactionsInDateRange(trans, bStart, bEnd) 150 | // End date should be the last day (inclusive, so subtract 1 day) 151 | results = append(results, &RangeTransactions{Start: bStart, End: bEnd.AddDate(0, 0, -1), Transactions: bTrans}) 152 | 153 | bStart = bEnd 154 | } 155 | 156 | return results 157 | } 158 | 159 | // RangeBalance contains the account balances and the start and end time of the date range 160 | type RangeBalance struct { 161 | Start, End time.Time 162 | Balances []*Account 163 | } 164 | 165 | // BalancesByPeriod will return the account balances for each period. 166 | func BalancesByPeriod(trans []*Transaction, per Period, rType RangeType) []*RangeBalance { 167 | tStart, tEnd := startEndTime(trans) 168 | 169 | boundaries := getDateBoundaries(per, tStart, tEnd) 170 | results := make([]*RangeBalance, 0, len(boundaries)-1) 171 | 172 | bStart := boundaries[0] 173 | for _, boundary := range boundaries[1:] { 174 | bEnd := boundary 175 | 176 | bTrans := TransactionsInDateRange(trans, bStart, bEnd) 177 | // End date should be the last day (inclusive, so subtract 1 day) 178 | results = append(results, &RangeBalance{Start: bStart, End: bEnd.AddDate(0, 0, -1), Balances: GetBalances(bTrans, []string{})}) 179 | 180 | if rType == RangePartition { 181 | bStart = bEnd 182 | } 183 | } 184 | 185 | return results 186 | } 187 | -------------------------------------------------------------------------------- /date_test.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | type boundCase struct { 9 | period Period 10 | start, end time.Time 11 | bounds []time.Time 12 | } 13 | 14 | var boundCases = []boundCase{ 15 | { 16 | PeriodYear, 17 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 18 | time.Date(2021, time.March, 23, 0, 0, 0, 0, time.UTC), 19 | []time.Time{ 20 | time.Date(2019, time.January, 01, 0, 0, 0, 0, time.UTC), 21 | time.Date(2020, time.January, 01, 0, 0, 0, 0, time.UTC), 22 | time.Date(2021, time.January, 01, 0, 0, 0, 0, time.UTC), 23 | time.Date(2022, time.January, 01, 0, 0, 0, 0, time.UTC), 24 | }, 25 | }, 26 | { 27 | PeriodSemiYear, 28 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 29 | time.Date(2021, time.September, 23, 0, 0, 0, 0, time.UTC), 30 | []time.Time{ 31 | time.Date(2019, time.January, 01, 0, 0, 0, 0, time.UTC), 32 | time.Date(2019, time.July, 01, 0, 0, 0, 0, time.UTC), 33 | time.Date(2020, time.January, 01, 0, 0, 0, 0, time.UTC), 34 | time.Date(2020, time.July, 01, 0, 0, 0, 0, time.UTC), 35 | time.Date(2021, time.January, 01, 0, 0, 0, 0, time.UTC), 36 | time.Date(2021, time.July, 01, 0, 0, 0, 0, time.UTC), 37 | time.Date(2022, time.January, 01, 0, 0, 0, 0, time.UTC), 38 | }, 39 | }, 40 | { 41 | PeriodSemiYear, 42 | time.Date(2019, time.September, 23, 0, 0, 0, 0, time.UTC), 43 | time.Date(2021, time.September, 23, 0, 0, 0, 0, time.UTC), 44 | []time.Time{ 45 | time.Date(2019, time.July, 01, 0, 0, 0, 0, time.UTC), 46 | time.Date(2020, time.January, 01, 0, 0, 0, 0, time.UTC), 47 | time.Date(2020, time.July, 01, 0, 0, 0, 0, time.UTC), 48 | time.Date(2021, time.January, 01, 0, 0, 0, 0, time.UTC), 49 | time.Date(2021, time.July, 01, 0, 0, 0, 0, time.UTC), 50 | time.Date(2022, time.January, 01, 0, 0, 0, 0, time.UTC), 51 | }, 52 | }, 53 | { 54 | PeriodDay, 55 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 56 | time.Date(2019, time.March, 28, 0, 0, 0, 0, time.UTC), 57 | []time.Time{ 58 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 59 | time.Date(2019, time.March, 24, 0, 0, 0, 0, time.UTC), 60 | time.Date(2019, time.March, 25, 0, 0, 0, 0, time.UTC), 61 | time.Date(2019, time.March, 26, 0, 0, 0, 0, time.UTC), 62 | time.Date(2019, time.March, 27, 0, 0, 0, 0, time.UTC), 63 | time.Date(2019, time.March, 28, 0, 0, 0, 0, time.UTC), 64 | time.Date(2019, time.March, 29, 0, 0, 0, 0, time.UTC), 65 | }, 66 | }, 67 | { 68 | PeriodWeek, 69 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 70 | time.Date(2019, time.April, 23, 0, 0, 0, 0, time.UTC), 71 | []time.Time{ 72 | time.Date(2019, time.March, 17, 0, 0, 0, 0, time.UTC), 73 | time.Date(2019, time.March, 24, 0, 0, 0, 0, time.UTC), 74 | time.Date(2019, time.March, 31, 0, 0, 0, 0, time.UTC), 75 | time.Date(2019, time.April, 07, 0, 0, 0, 0, time.UTC), 76 | time.Date(2019, time.April, 14, 0, 0, 0, 0, time.UTC), 77 | time.Date(2019, time.April, 21, 0, 0, 0, 0, time.UTC), 78 | time.Date(2019, time.April, 28, 0, 0, 0, 0, time.UTC), 79 | }, 80 | }, 81 | { 82 | Period2Week, 83 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 84 | time.Date(2019, time.April, 23, 0, 0, 0, 0, time.UTC), 85 | []time.Time{ 86 | time.Date(2019, time.March, 17, 0, 0, 0, 0, time.UTC), 87 | time.Date(2019, time.March, 31, 0, 0, 0, 0, time.UTC), 88 | time.Date(2019, time.April, 14, 0, 0, 0, 0, time.UTC), 89 | time.Date(2019, time.April, 28, 0, 0, 0, 0, time.UTC), 90 | }, 91 | }, 92 | { 93 | PeriodMonth, 94 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 95 | time.Date(2019, time.April, 23, 0, 0, 0, 0, time.UTC), 96 | []time.Time{ 97 | time.Date(2019, time.March, 01, 0, 0, 0, 0, time.UTC), 98 | time.Date(2019, time.April, 01, 0, 0, 0, 0, time.UTC), 99 | time.Date(2019, time.May, 01, 0, 0, 0, 0, time.UTC), 100 | }, 101 | }, 102 | { 103 | Period2Month, 104 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 105 | time.Date(2019, time.April, 23, 0, 0, 0, 0, time.UTC), 106 | []time.Time{ 107 | time.Date(2019, time.March, 01, 0, 0, 0, 0, time.UTC), 108 | time.Date(2019, time.May, 01, 0, 0, 0, 0, time.UTC), 109 | }, 110 | }, 111 | { 112 | PeriodQuarter, 113 | time.Date(2019, time.April, 23, 0, 0, 0, 0, time.UTC), 114 | time.Date(2019, time.May, 23, 0, 0, 0, 0, time.UTC), 115 | []time.Time{ 116 | time.Date(2019, time.April, 01, 0, 0, 0, 0, time.UTC), 117 | time.Date(2019, time.July, 01, 0, 0, 0, 0, time.UTC), 118 | }, 119 | }, 120 | { 121 | PeriodQuarter, 122 | time.Date(2019, time.July, 23, 0, 0, 0, 0, time.UTC), 123 | time.Date(2019, time.August, 23, 0, 0, 0, 0, time.UTC), 124 | []time.Time{ 125 | time.Date(2019, time.July, 01, 0, 0, 0, 0, time.UTC), 126 | time.Date(2019, time.October, 01, 0, 0, 0, 0, time.UTC), 127 | }, 128 | }, 129 | { 130 | PeriodQuarter, 131 | time.Date(2019, time.October, 23, 0, 0, 0, 0, time.UTC), 132 | time.Date(2019, time.November, 23, 0, 0, 0, 0, time.UTC), 133 | []time.Time{ 134 | time.Date(2019, time.October, 01, 0, 0, 0, 0, time.UTC), 135 | time.Date(2020, time.January, 01, 0, 0, 0, 0, time.UTC), 136 | }, 137 | }, 138 | { 139 | Period("Unknown"), 140 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 141 | time.Date(2019, time.April, 23, 0, 0, 0, 0, time.UTC), 142 | []time.Time{ 143 | time.Date(2019, time.March, 23, 0, 0, 0, 0, time.UTC), 144 | time.Date(2019, time.April, 23, 0, 0, 0, 0, time.UTC), 145 | }, 146 | }, 147 | } 148 | 149 | func TestDateBoundaries(t *testing.T) { 150 | for _, tc := range boundCases { 151 | bounds := getDateBoundaries(tc.period, tc.start, tc.end) 152 | if len(bounds) != len(tc.bounds) { 153 | t.Fatalf("Error(%s): expected `%d` bounds, got `%d` bounds", tc.period, len(tc.bounds), len(bounds)) 154 | } 155 | for i, b := range bounds { 156 | if !b.Equal(tc.bounds[i]) { 157 | t.Errorf("Error(%s): expected [%d] = `%s` , got `%s`", tc.period, i, tc.bounds[i].Format(time.RFC3339), b.Format(time.RFC3339)) 158 | } 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /decimal/decimal.go: -------------------------------------------------------------------------------- 1 | // Package decimal implements fixed-point decimal with accuracy to 3 digits of 2 | // precision after the decimal point. 3 | // 4 | // int64 is the underlying data type for speed of computation. However, using 5 | // an int64 cast to Decimal will not work, one of the "New" functions must 6 | // be used to get accurate results. 7 | // 8 | // The package multiplies every source value by 1000, and then does integer 9 | // math from that point forward, maintaining all values at that scale over 10 | // every operation. 11 | // 12 | // Note: For use in ledger. Cannot handle values over approx 900 trillion. 13 | package decimal 14 | 15 | import ( 16 | "errors" 17 | "strconv" 18 | "strings" 19 | ) 20 | 21 | // Decimal represents a fixed-point decimal. 22 | type Decimal int64 23 | 24 | // scaleFactor used for math operations, 25 | const scaleFactor = 1000 26 | 27 | // precision of 3 digits 28 | const precision = 3 29 | 30 | // Zero constant, to make initializations easier. 31 | const Zero = Decimal(0) 32 | 33 | // One constant, to make initializations easier. 34 | const One = Decimal(scaleFactor) 35 | 36 | // Parse max/min for whole number part 37 | const parseMax = (1<<63 - 1) / scaleFactor 38 | const parseMin = (-1 << 63) / scaleFactor 39 | 40 | // NewFromFloat converts a float64 to Decimal. Only 3 digits of precision after 41 | // the decimal point are preserved. 42 | func NewFromFloat(f float64) Decimal { 43 | return Decimal(f * float64(scaleFactor)) 44 | } 45 | 46 | // NewFromInt converts a int64 to Decimal. Multiplies by 1000 to get into 47 | // Decimal scale. 48 | func NewFromInt(i int64) Decimal { 49 | return Decimal(i) * scaleFactor 50 | } 51 | 52 | var errEmpty = errors.New("empty string") 53 | var errTooBig = errors.New("number too big") 54 | var errInvalid = errors.New("invalid syntax") 55 | 56 | // atoi64 is equivalent to strconv.Atoi 57 | func atoi64(s string) (bool, int64, error) { 58 | sLen := len(s) 59 | if sLen < 1 { 60 | return false, 0, errEmpty 61 | } 62 | if sLen > 18 { 63 | return false, 0, errTooBig 64 | } 65 | 66 | neg := false 67 | if s[0] == '-' { 68 | neg = true 69 | s = s[1:] 70 | if len(s) < 1 { 71 | return neg, 0, errEmpty 72 | } 73 | } 74 | 75 | var n int64 76 | for _, ch := range []byte(s) { 77 | ch -= '0' 78 | if ch > 9 { 79 | return neg, 0, errInvalid 80 | } 81 | n = n*10 + int64(ch) 82 | } 83 | if neg { 84 | n = -n 85 | } 86 | return neg, n, nil 87 | } 88 | 89 | // NewFromString returns a Decimal from a string representation. Throws an 90 | // error if integer parsing fails. 91 | func NewFromString(s string) (Decimal, error) { 92 | if whole, frac, split := strings.Cut(s, "."); split { 93 | neg, w, err := atoi64(whole) 94 | // if fractional portion exists, whole part can be empty 95 | if err != nil && err != errEmpty { 96 | return Zero, err 97 | } 98 | 99 | // overflow 100 | if w > parseMax || w < parseMin { 101 | return Zero, errTooBig 102 | } 103 | w *= int64(scaleFactor) 104 | 105 | // Parse up to *precision* digits and scale up 106 | var f int64 107 | var seen int 108 | for _, b := range frac { 109 | f *= 10 110 | if b < '0' || b > '9' { 111 | return Zero, errInvalid 112 | } 113 | f += int64(b - '0') 114 | seen++ 115 | if seen == precision { 116 | break 117 | } 118 | } 119 | for seen < precision { 120 | f *= 10 121 | seen++ 122 | } 123 | 124 | if neg { 125 | f = -f 126 | } 127 | return Decimal(w + f), nil 128 | } 129 | 130 | _, i, err := atoi64(s) 131 | if i > parseMax || i < parseMin { 132 | return Zero, errTooBig 133 | } 134 | i *= int64(scaleFactor) 135 | return Decimal(i), err 136 | } 137 | 138 | // IsZero returns true if d == 0 139 | func (d Decimal) IsZero() bool { 140 | return d == Zero 141 | } 142 | 143 | // Neg returns -d 144 | func (d Decimal) Neg() Decimal { 145 | return -d 146 | } 147 | 148 | // Sign returns: 149 | // 150 | // -1 if d < 0 151 | // 152 | // 0 if d == 0 153 | // 154 | // +1 if d > 0 155 | func (d Decimal) Sign() int { 156 | if d < 0 { 157 | return -1 158 | } else if d > 0 { 159 | return 1 160 | } 161 | return 0 162 | } 163 | 164 | // Add returns d + d1 165 | func (d Decimal) Add(d1 Decimal) Decimal { 166 | return d + d1 167 | } 168 | 169 | // Sub returns d - d1 170 | func (d Decimal) Sub(d1 Decimal) Decimal { 171 | return d - d1 172 | } 173 | 174 | // Mul returns d * d1 175 | func (d Decimal) Mul(d1 Decimal) Decimal { 176 | return (d * d1) / scaleFactor 177 | } 178 | 179 | // Div returns d / d1 180 | func (d Decimal) Div(d1 Decimal) Decimal { 181 | return (d * scaleFactor) / d1 182 | } 183 | 184 | // Abs returns the absolute value of the decimal 185 | func (d Decimal) Abs() Decimal { 186 | if d < 0 { 187 | return d.Neg() 188 | } 189 | return d 190 | } 191 | 192 | // Float64 returns the float64 value for d, and exact is always set to false. 193 | // The signature is this way to match big.Rat 194 | func (d Decimal) Float64() (f float64, exact bool) { 195 | return float64(d) / float64(scaleFactor), false 196 | } 197 | 198 | // Cmp compares the numbers represented by d and d1 and returns: 199 | // 200 | // -1 if d < d1 201 | // 0 if d == d1 202 | // +1 if d > d1 203 | func (d Decimal) Cmp(d1 Decimal) int { 204 | if d < d1 { 205 | return -1 206 | } else if d > d1 { 207 | return 1 208 | } 209 | return 0 210 | } 211 | 212 | // fmtFrac formats the fraction of v/10**prec (e.g., ".12345") into the 213 | // tail of buf. It returns the index where the 214 | // output bytes begin and the value v/10**prec. 215 | func fmtFrac(buf []byte, v uint64, prec int) (nw int, nv uint64) { 216 | w := len(buf) 217 | for range prec { 218 | digit := v % 10 219 | w-- 220 | buf[w] = byte(digit) + '0' 221 | v /= 10 222 | } 223 | w-- 224 | buf[w] = '.' 225 | return w, v 226 | } 227 | 228 | // fmtInt formats v into the tail of buf. 229 | // It returns the index where the output begins. 230 | func fmtInt(buf []byte, v uint64) int { 231 | w := len(buf) 232 | if v == 0 { 233 | w-- 234 | buf[w] = '0' 235 | } else { 236 | for v > 0 { 237 | w-- 238 | buf[w] = byte(v%10) + '0' 239 | v /= 10 240 | } 241 | } 242 | return w 243 | } 244 | 245 | // StringFixedBank returns a banker rounded fixed-point string with 2 digits 246 | // after the decimal point. 247 | // 248 | // Example: 249 | // 250 | // NewFromFloat(5.455).StringFixedBank() == "5.46" 251 | // NewFromFloat(5.445).StringFixedBank() == "5.44" 252 | func (d Decimal) StringFixedBank() string { 253 | var buf [24]byte 254 | w := len(buf) 255 | 256 | u := uint64(d) 257 | neg := d < 0 258 | if neg { 259 | u = -u 260 | } 261 | 262 | // Bank rounding 263 | rem := u % 10 264 | u /= 10 265 | if rem > 5 || (rem == 5 && u%2 != 0) { 266 | u++ 267 | } 268 | 269 | // fmt functions from time.Duration 270 | w, u = fmtFrac(buf[:w], u, precision-1) 271 | w = fmtInt(buf[:w], u) 272 | 273 | if neg { 274 | w-- 275 | buf[w] = '-' 276 | } 277 | 278 | return string(buf[w:]) 279 | } 280 | 281 | // StringTruncate returns the whole-number (Int) part of d. 282 | // 283 | // Example: 284 | // 285 | // NewFromFloat(5.44).StringTruncate() == "5" 286 | func (d Decimal) StringTruncate() string { 287 | whole := d / scaleFactor 288 | return strconv.FormatInt(int64(whole), 10) 289 | } 290 | 291 | // StringRound returns the nearest rounded whole-number (Int) part of d. 292 | // Example: 293 | // 294 | // NewFromFloat(5.5).StringRound() == "6" 295 | // NewFromFloat(5.4).StringRound() == "5" 296 | // NewFromFloat(-5.4).StringRound() == "5" 297 | // NewFromFloat(-5.5).StringRound() == "6" 298 | func (d Decimal) StringRound() string { 299 | whole := d / scaleFactor 300 | frac := (d % scaleFactor) 301 | neg := false 302 | if frac < 0 { 303 | frac = -frac 304 | neg = true 305 | } 306 | if frac >= (5 * (scaleFactor / 10)) { 307 | if neg { 308 | whole-- 309 | } else { 310 | whole++ 311 | } 312 | } 313 | return strconv.FormatInt(int64(whole), 10) 314 | } 315 | -------------------------------------------------------------------------------- /decimal/decimal_test.go: -------------------------------------------------------------------------------- 1 | package decimal 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | "testing" 7 | 8 | sdec "github.com/shopspring/decimal" 9 | ) 10 | 11 | type testCase struct { 12 | name string 13 | Result, Input string 14 | } 15 | 16 | var testCases = []testCase{ 17 | { 18 | "multiply", 19 | NewFromFloat(48.0).StringFixedBank(), 20 | NewFromInt(6).Mul(NewFromInt(8)).StringFixedBank(), 21 | }, 22 | { 23 | "divide", 24 | NewFromFloat(6.0).StringFixedBank(), 25 | NewFromInt(48).Div(NewFromInt(8)).StringFixedBank(), 26 | }, 27 | { 28 | "divide-1", 29 | NewFromFloat(11.111).StringFixedBank(), 30 | NewFromInt(100).Div(NewFromInt(9)).StringFixedBank(), 31 | }, 32 | { 33 | "sum", 34 | NewFromFloat(234.56).StringFixedBank(), 35 | NewFromFloat(123.12).Add(NewFromInt(111)).Add(NewFromFloat(0.44)).StringFixedBank(), 36 | }, 37 | { 38 | "bankrounduppos", 39 | NewFromFloat(234.56).StringFixedBank(), 40 | NewFromFloat(234.555).StringFixedBank(), 41 | }, 42 | { 43 | "bankrounddownpos", 44 | NewFromFloat(234.54).StringFixedBank(), 45 | NewFromFloat(234.545).StringFixedBank(), 46 | }, 47 | { 48 | "bankroundupneg", 49 | "-234.56", 50 | NewFromFloat(-234.555).StringFixedBank(), 51 | }, 52 | { 53 | "bankrounddownneg", 54 | "-234.54", 55 | NewFromFloat(-234.545).StringFixedBank(), 56 | }, 57 | { 58 | "rounduppos", 59 | NewFromFloat(234.56).StringFixedBank(), 60 | NewFromFloat(234.556).StringFixedBank(), 61 | }, 62 | { 63 | "rounddownpos", 64 | NewFromFloat(234.55).StringFixedBank(), 65 | NewFromFloat(234.554).StringFixedBank(), 66 | }, 67 | { 68 | "roundupneg", 69 | "-234.56", 70 | NewFromFloat(-234.556).StringFixedBank(), 71 | }, 72 | { 73 | "rounddownneg", 74 | "-234.55", 75 | NewFromFloat(-234.554).StringFixedBank(), 76 | }, 77 | { 78 | "truncate", 79 | NewFromInt(234).StringTruncate(), 80 | NewFromFloat(234.554).StringTruncate(), 81 | }, 82 | { 83 | "2digits-1", 84 | "1.00", 85 | One.StringFixedBank(), 86 | }, 87 | { 88 | "2digits-4.5", 89 | "4.50", 90 | NewFromFloat(4.5).StringFixedBank(), 91 | }, 92 | { 93 | "roundintuppos", 94 | "6", 95 | NewFromFloat(5.6).StringRound(), 96 | }, 97 | { 98 | "roundintdownpos", 99 | "5", 100 | NewFromFloat(5.4).StringRound(), 101 | }, 102 | { 103 | "roundintupneg", 104 | "-5", 105 | NewFromFloat(-5.4).StringRound(), 106 | }, 107 | { 108 | "roundintdownneg", 109 | "-6", 110 | NewFromFloat(-5.6).StringRound(), 111 | }, 112 | { 113 | "negfrac", 114 | "-0.43", 115 | NewFromFloat(-0.43).StringFixedBank(), 116 | }, 117 | { 118 | "sub", 119 | "5.12", 120 | NewFromFloat(5.56).Sub(NewFromFloat(0.44)).StringFixedBank(), 121 | }, 122 | { 123 | "neg", 124 | "-5.12", 125 | NewFromFloat(5.12).Neg().StringFixedBank(), 126 | }, 127 | { 128 | "abs-1", 129 | "5.12", 130 | NewFromFloat(-5.12).Abs().StringFixedBank(), 131 | }, 132 | { 133 | "abs-1", 134 | "5.12", 135 | NewFromFloat(5.12).Abs().StringFixedBank(), 136 | }, 137 | } 138 | 139 | func TestDecimal(t *testing.T) { 140 | for _, tc := range testCases { 141 | if tc.Result != tc.Input { 142 | t.Errorf("Error(%s): expected \n`%s`, \ngot \n`%s`", tc.name, tc.Result, tc.Input) 143 | } 144 | } 145 | } 146 | 147 | func TestFloat(t *testing.T) { 148 | d := NewFromFloat(5.56) 149 | f := float64(5.56) 150 | if df, _ := d.Float64(); df != f { 151 | t.Error("Float64 not exact") 152 | } 153 | } 154 | 155 | func TestCompare(t *testing.T) { 156 | l := NewFromInt(5) 157 | h := NewFromInt(10) 158 | z := NewFromInt(0) 159 | 160 | if !z.IsZero() { 161 | t.Error("zero failed") 162 | } 163 | 164 | if h.Cmp(l) != 1 || l.Cmp(h) != -1 || z.Cmp(Zero) != 0 { 165 | t.Error("compare fail") 166 | } 167 | } 168 | 169 | func TestSign(t *testing.T) { 170 | n := NewFromInt(-5) 171 | p := NewFromInt(5) 172 | z := NewFromInt(0) 173 | 174 | if z.Sign() != 0 { 175 | t.Error("zero failed") 176 | } 177 | 178 | if n.Sign() != -1 || p.Sign() != 1 { 179 | t.Error("sign fail") 180 | } 181 | } 182 | 183 | var testParseCases = []testCase{ 184 | { 185 | "negzero", 186 | "-0.43", 187 | "-0.43", 188 | }, 189 | { 190 | "poszero", 191 | "0.43", 192 | "0.43", 193 | }, 194 | { 195 | "3digit", 196 | "5.56", 197 | "5.564", 198 | }, 199 | { 200 | "truncateinput", 201 | "5.56", 202 | "5.56432342", 203 | }, 204 | { 205 | "precise", 206 | "16.24", 207 | "16.24", 208 | }, 209 | { 210 | "fuzz-1", 211 | "0.00", 212 | "0.0051", 213 | }, 214 | { 215 | "fuzz-2", 216 | "8.00", 217 | "8.005", 218 | }, 219 | { 220 | "fuzz-3", 221 | "0.00", 222 | "0.005", 223 | }, 224 | { 225 | "fuzz-4", 226 | "1.00", 227 | "0.997", 228 | }, 229 | { 230 | "fuzz-5", 231 | "2200000000000021.00", 232 | "2200000000000021", 233 | }, 234 | { 235 | "fuzz-6", 236 | "0.01", 237 | "0.010e1", 238 | }, 239 | { 240 | "fuzz-7", 241 | "-8.00", 242 | "-7.995", 243 | }, 244 | { 245 | "fuzz-8", 246 | "-9.00", 247 | "-8.995", 248 | }, 249 | { 250 | "fuzz-9", 251 | "8.00", 252 | "7.995", 253 | }, 254 | { 255 | "fuzz-10", 256 | "9.00", 257 | "8.995", 258 | }, 259 | { 260 | "fuzz-11", 261 | "-7.98", 262 | "-7.985", 263 | }, 264 | { 265 | "fuzz-12", 266 | "-8.98", 267 | "-8.985", 268 | }, 269 | { 270 | "fuzz-13", 271 | "7.98", 272 | "7.985", 273 | }, 274 | { 275 | "fuzz-14", 276 | "8.98", 277 | "8.984", 278 | }, 279 | { 280 | "fuzz-15", 281 | "-8.00", 282 | "-7.999", 283 | }, 284 | { 285 | "fuzz-16", 286 | "-9.00", 287 | "-8.999", 288 | }, 289 | { 290 | "fuzz-17", 291 | "8.00", 292 | "7.999", 293 | }, 294 | { 295 | "fuzz-18", 296 | "9.00", 297 | "8.999", 298 | }, 299 | { 300 | "error-1", 301 | errTooBig.Error(), 302 | "100000000000000000", 303 | }, 304 | { 305 | "error-2", 306 | errTooBig.Error(), 307 | "10000000000000000", 308 | }, 309 | { 310 | "error-3", 311 | errTooBig.Error(), 312 | "10000000000000000.56", 313 | }, 314 | { 315 | "error-4", 316 | errInvalid.Error(), 317 | "0.e0", 318 | }, 319 | { 320 | "error-5", 321 | errTooBig.Error(), 322 | "5555555555555555555555555550000000000000000", 323 | }, 324 | { 325 | "error-6", 326 | errEmpty.Error(), 327 | "-", 328 | }, 329 | { 330 | "error-7", 331 | errEmpty.Error(), 332 | "", 333 | }, 334 | { 335 | "error-badint-1", 336 | errInvalid.Error(), 337 | "1QZ.56", 338 | }, 339 | { 340 | "error-expr-1", 341 | errInvalid.Error(), 342 | "(123 * 6)", 343 | }, 344 | { 345 | "missingwhole", 346 | "0.50", 347 | ".50", 348 | }, 349 | { 350 | "negmissingwhole", 351 | "-0.50", 352 | "-.50", 353 | }, 354 | { 355 | "missingfrac", 356 | "5.00", 357 | "5.", 358 | }, 359 | { 360 | "neg-missingfrac", 361 | "-5.00", 362 | "-5.", 363 | }, 364 | { 365 | "just-a-decimal", 366 | "0.00", 367 | ".", 368 | }, 369 | } 370 | 371 | func TestStringParse(t *testing.T) { 372 | for _, tc := range testParseCases { 373 | d, err := NewFromString(tc.Input) 374 | if strings.HasPrefix(tc.name, "error") { 375 | if err == nil { 376 | t.Fatalf("Error(%s): expected error `%s`", tc.name, tc.Result) 377 | } 378 | if err.Error() != tc.Result { 379 | t.Fatalf("Error(%s): expected `%s`, got `%s`", tc.name, tc.Result, err) 380 | } 381 | } 382 | if !strings.HasPrefix(tc.name, "error") && err != nil { 383 | t.Fatalf("Error(%s): unexpected error `%s`", tc.name, err) 384 | } 385 | if !strings.HasPrefix(tc.name, "error") && tc.Result != d.StringFixedBank() { 386 | t.Errorf("Error(%s): expected \n`%s`, \ngot \n`%s`", tc.name, tc.Result, d.StringFixedBank()) 387 | } 388 | } 389 | } 390 | 391 | func FuzzStringParse(f *testing.F) { 392 | f.Fuzz(func(t *testing.T, s string) { 393 | if _, after, split := strings.Cut(s, "."); split { 394 | if len(after) > 3 { 395 | return 396 | } 397 | } 398 | sd, serr := sdec.NewFromString(s) 399 | if serr != nil { 400 | return 401 | } 402 | d, err := NewFromString(s) 403 | if err != nil { 404 | return 405 | } 406 | ss := strings.TrimPrefix(sd.StringFixedBank(2), "-") 407 | ds := strings.TrimPrefix(d.StringFixedBank(), "-") 408 | 409 | if ds != ss { 410 | t.Fatalf("no match: decimal \n`%s`, \nsdec \n `%s`", ds, ss) 411 | } 412 | }) 413 | } 414 | 415 | func BenchmarkNewFromString(b *testing.B) { 416 | numbers := []string{"10.0", "245.6", "354", "2.456", "-31.2"} 417 | for range b.N { 418 | for _, numStr := range numbers { 419 | NewFromString(numStr) 420 | } 421 | } 422 | } 423 | 424 | func BenchmarkStringFixedBank(b *testing.B) { 425 | var numbers [1000]Decimal 426 | for i := range len(numbers) { 427 | numbers[i] = NewFromFloat(rand.Float64() * 100000) 428 | if i%2 == 0 { 429 | numbers[i] *= -1 430 | } 431 | } 432 | b.ResetTimer() 433 | for range b.N { 434 | for _, num := range numbers { 435 | num.StringFixedBank() 436 | } 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/howeyc/ledger 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/alfredxing/calc v0.0.0-20180827002445-77daf576f976 7 | github.com/andybalholm/brotli v1.0.6 8 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b 9 | github.com/ivanpirog/coloredcobra v1.0.1 10 | github.com/jbrukh/bayesian v0.0.0-20200318221351-d726b684ca4a 11 | github.com/joyt/godate v0.0.0-20150226210126-7151572574a7 12 | github.com/juztin/numeronym v0.0.0-20160223091026-859fcc2918e2 13 | github.com/lucasb-eyer/go-colorful v1.2.0 14 | github.com/mattn/go-isatty v0.0.20 15 | github.com/patrickmn/go-cache v2.1.0+incompatible 16 | github.com/pelletier/go-toml v1.9.5 17 | github.com/shopspring/decimal v1.3.1 18 | github.com/spf13/cobra v1.7.0 19 | golang.org/x/term v0.13.0 20 | golang.org/x/time v0.3.0 21 | ) 22 | 23 | require ( 24 | github.com/fatih/color v1.15.0 // indirect 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 26 | github.com/mattn/go-colorable v0.1.13 // indirect 27 | github.com/spf13/pflag v1.0.5 // indirect 28 | golang.org/x/sys v0.13.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alfredxing/calc v0.0.0-20180827002445-77daf576f976 h1:+jyVKPjl5Y39thM0ZlVrRqKjSO/Upr5tP9ZQGELv8gw= 2 | github.com/alfredxing/calc v0.0.0-20180827002445-77daf576f976/go.mod h1:/HQknSiD7YKT15DoHXuiXezQfNPBUm8PeqFaTxeA3HU= 3 | github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= 4 | github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 7 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 8 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 9 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 10 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= 11 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= 12 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 13 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 14 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 15 | github.com/ivanpirog/coloredcobra v1.0.1 h1:aURSdEmlR90/tSiWS0dMjdwOvCVUeYLfltLfbgNxrN4= 16 | github.com/ivanpirog/coloredcobra v1.0.1/go.mod h1:iho4nEKcnwZFiniGSdcgdvRgZNjxm+h20acv8vqmN6Q= 17 | github.com/jbrukh/bayesian v0.0.0-20200318221351-d726b684ca4a h1:gbdjhSslIoRRiSSLCP3kKuLmqAJGmhnPVhIyf6Dbw34= 18 | github.com/jbrukh/bayesian v0.0.0-20200318221351-d726b684ca4a/go.mod h1:SELxwZQq/mPnfPCR2mchLmT4TQaPJvYtLcCtDWSM7vM= 19 | github.com/joyt/godate v0.0.0-20150226210126-7151572574a7 h1:2wH5antjhmU3EuWyidm0lJ4B9hGMpl5lNRo+M9uGJ5A= 20 | github.com/joyt/godate v0.0.0-20150226210126-7151572574a7/go.mod h1:R+UgFL3iylLhx9N4w35zZ2HdhDlgorRDx4SxbchWuN0= 21 | github.com/juztin/numeronym v0.0.0-20160223091026-859fcc2918e2 h1:jrs0oyU9XY7MlTHbNxecqFgY+fgEENZdP4Z8FZln/pw= 22 | github.com/juztin/numeronym v0.0.0-20160223091026-859fcc2918e2/go.mod h1:uVDl4OnjvPk07IzoXF/dFM7nBYqAKdJsz4e9xjjWo7Q= 23 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 24 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 25 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 26 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 27 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 28 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 29 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 30 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 31 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 32 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 33 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 34 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 35 | github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= 36 | github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 37 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 38 | github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= 39 | github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 40 | github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= 41 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 42 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 43 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 44 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 45 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 47 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 51 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= 53 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 54 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 55 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 56 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 57 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 58 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 59 | -------------------------------------------------------------------------------- /include_test.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestIncludeSimple(t *testing.T) { 9 | trans, err := ParseLedgerFile("testdata/ledgerRoot.dat") 10 | if err != nil { 11 | t.Fatal(err) 12 | } 13 | bals := GetBalances(trans, []string{"Assets"}) 14 | if bals[0].Balance.StringRound() != "50" { 15 | t.Fatal(errors.New("should be 50")) 16 | } 17 | } 18 | 19 | func TestIncludeGlob(t *testing.T) { 20 | trans, err := ParseLedgerFile("testdata/ledgerRootGlob.dat") 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | bals := GetBalances(trans, []string{"Assets"}) 25 | if bals[0].Balance.StringRound() != "80" { 26 | t.Fatal(errors.New("should be 80")) 27 | } 28 | } 29 | 30 | func TestIncludeUnbalanced(t *testing.T) { 31 | _, err := ParseLedgerFile("testdata/ledgerRootUnbalanced.dat") 32 | if err.Error() != "testdata/ledger-2021-05.dat:12: unable to parse transaction: unable to balance transaction: no empty account to place extra balance" { 33 | t.Fatal(err) 34 | } 35 | } 36 | 37 | func TestIncludeNonExistant(t *testing.T) { 38 | _, err := ParseLedgerFile("testdata/ledgerRootNonExist.dat") 39 | if err.Error() != "testdata/ledgerRootNonExist.dat:3: unable to include file(ledger-xxxxx.dat): not found" { 40 | t.Fatal(err) 41 | } 42 | } 43 | 44 | func TestNonExistant(t *testing.T) { 45 | _, err := ParseLedgerFile("testdata/ledger-xxxxx.dat") 46 | if err.Error() != "open testdata/ledger-xxxxx.dat: no such file or directory" { 47 | t.Fatal(err) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ledger/book/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Chris Howey"] 3 | language = "en" 4 | multilingual = false 5 | src = "src-book" 6 | title = "Guide to Ledger" 7 | description = "Plain Text Accounting" 8 | 9 | [build] 10 | build-dir = "html-book" 11 | -------------------------------------------------------------------------------- /ledger/book/genbook.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | mkdir src-book 4 | 5 | pushd src 6 | for SRCFILE in $(find . -name "*.md") 7 | do 8 | mdexec -template='```sh 9 | {{.Output}}```' $SRCFILE > ../src-book/$SRCFILE 10 | done 11 | popd 12 | 13 | mdbook build 14 | rsync -a src/webshots html-book/ 15 | rsync -a src/consoleshots html-book/ 16 | 17 | rm -rf src-book 18 | -------------------------------------------------------------------------------- /ledger/book/genperf.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cbench --export-markdown perf-stats.md "ledger stats" "../ledger stats" "hledger stats" 4 | cbench --export-markdown perf-bal.md "ledger bal" "../ledger bal" "hledger bal" 5 | cbench --export-markdown perf-reg.md "ledger reg" "../ledger reg" "hledger reg" 6 | cbench --export-markdown perf-print.md "ledger print" "../ledger print" "hledger print" 7 | 8 | echo "# Performance" > perf.md 9 | echo "" >> perf.md 10 | echo "Comparison between various ledger-like applications:" >> perf.md 11 | echo "" >> perf.md 12 | echo "- ledger-go" >> perf.md 13 | echo "- [ledger-cli](https://ledger-cli.org)" >> perf.md 14 | echo "- [hledger](https://hledger.org)" >> perf.md 15 | echo "" >> perf.md 16 | 17 | echo "## Stats" >> perf.md 18 | echo "" >> perf.md 19 | cat perf-stats.md | sed -e 's/\.\.\/ledger/ledger-go/g' | sed -e 's/ledger /ledger-cli /g' | sed -e 's/hledger-cli/hledger/g' >> perf.md 20 | echo "" >> perf.md 21 | 22 | echo "## Balance" >> perf.md 23 | echo "" >> perf.md 24 | cat perf-bal.md | sed -e 's/\.\.\/ledger/ledger-go/g' | sed -e 's/ledger /ledger-cli /g' | sed -e 's/hledger-cli/hledger/g' >> perf.md 25 | echo "" >> perf.md 26 | 27 | echo "## Register" >> perf.md 28 | echo "" >> perf.md 29 | cat perf-reg.md | sed -e 's/\.\.\/ledger/ledger-go/g' | sed -e 's/ledger /ledger-cli /g' | sed -e 's/hledger-cli/hledger/g' >> perf.md 30 | echo "" >> perf.md 31 | 32 | echo "## Print" >> perf.md 33 | echo "" >> perf.md 34 | cat perf-print.md | sed -e 's/\.\.\/ledger/ledger-go/g' | sed -e 's/ledger /ledger-cli /g' | sed -e 's/hledger-cli/hledger/g' >> perf.md 35 | echo "" >> perf.md 36 | 37 | rm perf-stats.md perf-bal.md perf-reg.md perf-print.md 38 | mv perf.md src/Performance.md 39 | -------------------------------------------------------------------------------- /ledger/book/src/01_01_Installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | There are multiple ways to install the Ledger CLI tool. 4 | Choose any one of the methods below that best suit your needs. 5 | 6 | ## Pre-compiled binaries 7 | 8 | Executable binaries are available for download on the 9 | [GitHub Releases page][releases]. 10 | Download the binary for your platform (Windows, macOS, or Linux) and extract 11 | the archive. 12 | The archive contains the `ledger` executable. 13 | 14 | To make it easier to run, put the path to the binary into your `PATH`. 15 | 16 | [releases]: https://github.com/howeyc/ledger/releases 17 | 18 | ## Build from source using Go 19 | 20 | To build the `ledger` executable from source, you will first need to install Go 21 | Follow the instructions on the [Go installation page]. 22 | ledger currently requires at least Go version 1.17. 23 | 24 | Once you have installed Go, the following command can be used to build and 25 | install ledger: 26 | 27 | ```sh 28 | go install github.com/howeyc/ledger/ledger@latest 29 | ``` 30 | 31 | [Go installation page]: https://go.dev/doc/install 32 | 33 | This will automatically download ledger, build it, and install it in Go's global 34 | binary directory (`~/go/bin/` by default). 35 | 36 | ## Archlinux AUR 37 | 38 | If you happen to be using Archlinux, you can use the port in AUR. 39 | 40 | ```sh 41 | yay -S ledger-go 42 | ``` 43 | 44 | -------------------------------------------------------------------------------- /ledger/book/src/01_02_FileFormat.md: -------------------------------------------------------------------------------- 1 | # Ledger File Format 2 | 3 | Maintaining your Transaction Record in `ledger` format. 4 | 5 | Tracking your transactions for analysis with `ledger` is as easy as writing some 6 | text to a file in a very human-readable format. 7 | The format is _structured_ but appears _unstructured_ to many because it doesn't 8 | use curly brackets, key-value pairs, or other special characters to model 9 | transaction data. 10 | Instead, the things that matter are just having enough whitespace between 11 | certain elements in order for the `ledger` parser to understand the difference 12 | between dates, amounts, and so on. 13 | 14 | Start your favorite text editor and you'll get started on the path to personal 15 | finance greatness. 16 | 17 | ## Terminology 18 | 19 | * Transaction - Series of consecutive lines that represent the move of money 20 | from one account to one (*or more*) other accounts. 21 | * Transaction Date - Date the transaction occurred. 22 | * Payee - Description following on the same line as the Date. Usually the place 23 | of business or person the transaction occurred at/with. 24 | * Posting - Line containing account and (*optionally*) amount. 25 | 26 | ## Basic transaction format 27 | 28 | The basic format of a `ledger` transaction, shown below. 29 | 30 | ```ledger 31 | 2017-06-26 Commonplace Coffee 32 | Assets:Cash:Wallet -3.00 33 | Expenses:Restaurants:Coffee 3.00 34 | 35 | ``` 36 | 37 | In the example, line 1 shows the _transaction date_ and _payee_. 38 | Lines 2 and 3 are two _postings_ comprised of an _account_ and an _amount_. 39 | 40 | All transactions must balance. That is, the amount credited must 41 | equal the amount debited: credits minus debits must equal zero. 42 | In other words, the sum of all _postings_ must equal zero. 43 | 44 | A transaction must have at least two _postings_. There is not limit on the 45 | number of _postings_ per transaction. 46 | 47 | Note the _accounts_ used in this example. 48 | One begins with `Expenses` and the other begins with `Assets`. 49 | Expenses are _credited_ because the money flows to them. 50 | Assets are credited when you add funds and debited when you move money to 51 | something else. 52 | In this transaction, you're deducting money from an account representing your 53 | wallet and adding it to an expense representing your coffee spending. 54 | 55 | `ledger` has some great conveniences that ease entry. 56 | One such convenience is that `ledger` allows transactions to omit the _amount_ 57 | on a single _posting_. 58 | The missing amount is calculated and is equal to whatever amount is necessary 59 | to balance the transaction. 60 | 61 | ```ledger 62 | 2017-06-26 Commonplace Coffee 63 | Assets:Cash:Wallet 64 | Expenses:Restaurants:Coffee 3.00 65 | 66 | ``` 67 | 68 | You can also supply comments for a transaction or posting. 69 | Postings can only have one comment line but transactions can have as many as 70 | you want. 71 | 72 | ```ledger 73 | ; cold brew 74 | ; morning 75 | 2017-06-26 Commonplace Coffee 76 | Expenses:Restaurants:Coffee 3.00 ; Grande 77 | Assets:Cash:Wallet -3.00 78 | 79 | ``` 80 | 81 | ## Ledger file 82 | 83 | A ledger file is a series of transactions separated by blank lines in between 84 | them. Here's an example. 85 | 86 | ```ledger 87 | 2013/01/02 McDonald's #24233 HOUSTON TX 88 | Expenses:Dining Out:Fast Food 5.60 89 | Assets:Cash:Wallet 90 | 91 | 2013/01/02 Burger King 92 | Expenses:Dining Out:Fast Food 15.60 93 | Assets:Cash:Wallet 94 | 95 | 2013/01/02 Purchase 100 IVV 96 | Assets:Bank:Checking -15000 97 | Assets:Investments:IVV 98 | Expenses:Investments:Commissions 4.99 99 | 100 | ``` 101 | 102 | You may be wondering how we track stocks, currencies, commodities, etc. 103 | All of those are reporting considerations, transactions are all that's contained 104 | in a ledger file. Simplicity of the file format is a guiding principle of ledger. 105 | 106 | Reporting functions available in ledger are very powerful, and will be introduced 107 | in later chapters. 108 | 109 | ## Differences from Other Ledger 110 | 111 | The file format supported by this version of ledger is heavily inspired by the 112 | format defined by the other [ledger](https://www.ledger-cli.org/). However, 113 | this version supports only the most basic of features in the ledger file itself. 114 | 115 | ### No Currencies or Commodities 116 | 117 | There is no support for prepending a currency token (such as $) to a number. Nor 118 | is there support for appending a token or string to signify a commodity (such 119 | as "APPL", "BTC", or "USD"). 120 | 121 | All amounts must be numbers. Ledger balances and moves amounts (numbers) 122 | between accounts in transactions. The significance of what a number means or 123 | represents is entirely up to the user. 124 | 125 | **Note:** Even though there is no support for commodities in the ledger file 126 | format, support for commodities exists in the web reporting features. 127 | 128 | ### Minimal Command Directive Support 129 | 130 | The other ledger supports many [Command Directives](https://www.ledger-cli.org/3.0/doc/ledger3.html#Command-Directives). 131 | 132 | The only supported directives are: 133 | 134 | * include - to import/include transactions of another ledger file. 135 | * account - parsed but ignored. 136 | 137 | All other directives will cause errors in this application as they will be 138 | assumed to be a line starting a transaction. 139 | 140 | ### Transactions are basic 141 | 142 | * No metadata support 143 | * No "state" (pending, cleared, ...) 144 | * No virtual postings 145 | * No balance assertions 146 | 147 | Postings are account and an optional amount. 148 | -------------------------------------------------------------------------------- /ledger/book/src/01_03_RunningLedger.md: -------------------------------------------------------------------------------- 1 | # Running ledger 2 | 3 | Starting ledger provides us with a list of all the commands that are available. 4 | 5 | ```sh 6 | ledger 7 | ``` 8 | 9 | This produces the following output. 10 | 11 | ``` 12 | Plain text accounting 13 | 14 | Usage: 15 | ledger [command] 16 | 17 | Available Commands: 18 | balance Print account balances 19 | completion generate the autocompletion script for the specified shell 20 | equity Print account equity as transaction 21 | help Help about any command 22 | import Import transactions from csv to ledger format 23 | export Export transactions from ledger format to CSV format 24 | lint Check ledger for errors 25 | print Print transactions in ledger file format 26 | register Print register of transactions 27 | stats A small report of transaction stats 28 | version Version of ledger 29 | web Web service 30 | 31 | Flags: 32 | -f, --file string ledger file (default is $LEDGER_FILE) (default "") 33 | -h, --help help for ledger 34 | 35 | Use "ledger [command] --help" for more information about a command. 36 | ``` 37 | 38 | In order to run any command we must specify the ledger file. This is done with 39 | either the **-f** or **--file** flag. However, since this needs to be included 40 | so often, it can also be specified via the environment variable 41 | **LEDGER_FILE**. 42 | 43 | It is encouraged to setup this **LEDGER_FILE** to require less typing every time 44 | a command is run. 45 | -------------------------------------------------------------------------------- /ledger/book/src/02_Accounts.md: -------------------------------------------------------------------------------- 1 | # Accounts 2 | 3 | Run `ledger -f ledger.dat accounts` to see an account list. 4 | 5 | `$ ledger -f ledger.dat accounts` 6 | 7 | ## Only Leaf (Max Depth) Accounts 8 | 9 | If we are only interested in the highest depth accounts and not interested 10 | in seeing all the parent account levels we can get that, just 11 | run `ledger -f ledger.dat accounts -l` 12 | 13 | `$ ledger -f ledger.dat accounts -l` 14 | 15 | ## Matching Depth Accounts 16 | 17 | This is mostly useful for autocomplete functions. You can use this to get 18 | accounts matching a filter, and at the same depth as the filter. 19 | 20 | For instance, let's get all Assets accounts by running 21 | `ledger -f ledger.dat accounts -m Assets:` 22 | 23 | `$ ledger -f ledger.dat accounts -m Assets:` 24 | 25 | -------------------------------------------------------------------------------- /ledger/book/src/02_Balance.md: -------------------------------------------------------------------------------- 1 | # Balance 2 | 3 | Run `ledger -f ledger.dat bal` to see a balance report. 4 | 5 | `$ ledger -f ledger.dat bal` 6 | 7 | ## Net Worth 8 | 9 | You can show specific accounts by applying a filter, which is case senstive. 10 | For example, let's get our net worth, 11 | run `ledger -f ledger.dat bal Assets Liabilities` 12 | 13 | `$ ledger -f ledger.dat bal Assets Liabilities` 14 | 15 | ## By Period 16 | 17 | We can see our balances segmented by a time period. For example, let's see all 18 | our expenses for each month, 19 | run `ledger -f ledger.dat --period Monthly bal Expenses` 20 | 21 | `$ ledger -f ledger.dat --period Monthly bal Expenses` 22 | 23 | ## Account Depth 24 | 25 | That's a lot of accounts, let's trim it down to see it summed up to the second 26 | level. Run `ledger -f ledger.dat --period Monthly --depth 2 bal Expenses` 27 | 28 | `$ ledger -f ledger.dat --period Monthly --depth 2 bal Expenses` 29 | -------------------------------------------------------------------------------- /ledger/book/src/02_Equity.md: -------------------------------------------------------------------------------- 1 | # Equity 2 | 3 | Some users like to keep ledger files for each year. To aid in creating a new 4 | starting balance for the next file, we can use the `ledger equity` command to 5 | generate the required transaction to have the correct starting balances. 6 | 7 | Let's start 2022, using all transactions up to the end of 2021. As the end date 8 | on the command line is not included, we can use 2022/01/01 as the end date. 9 | 10 | Run `ledger -f ledger.dat equity -e "2022/01/01"` 11 | 12 | `$ ledger -f ledger.dat equity -e "2022/01/01"` 13 | -------------------------------------------------------------------------------- /ledger/book/src/02_Export.md: -------------------------------------------------------------------------------- 1 | # Export 2 | 3 | We can export transactions in CSV format. 4 | 5 | ## Example 6 | 7 | Run `ledger -f ledger.dat export` 8 | 9 | `$ ledger -f ledger.dat export` 10 | 11 | By default columns are comma separated. To use another delimiter use the `--delimiter` flag e.g. 12 | 13 | `ledger -f ledger.dat --delimiter $'\t' export` 14 | 15 | `$'\t'` will produce a literal tab character in Bash shell environment. 16 | 17 | `$ ledger -f ledger.dat --delimiter ' ' export` 18 | -------------------------------------------------------------------------------- /ledger/book/src/02_Import.md: -------------------------------------------------------------------------------- 1 | # Import 2 | 3 | We can import transactions in CSV format, and product ledger transactions. 4 | Transactions are classified using best-likely match based on payee descriptions. 5 | Matches do not need to be exact matches, it's based on probability determined 6 | by learning from existing transactions. The more existing transactions in your 7 | ledger file, the better the matches will be. 8 | 9 | ## Example 10 | 11 | Example transactions from your credit card csv download. 12 | 13 | `$ cat transactions.csv` 14 | 15 | Let's run our import, making sure to specify the correct date-format to match 16 | the CSV file. 17 | 18 | Run `ledger -f ledger.dat --date-format "01/02/06" import MasterCard transactions.csv` 19 | 20 | `$ ledger -f ledger.dat --date-format "01/02/06" import MasterCard transactions.csv` 21 | 22 | These are not written to our ledger file, just displayed. Once we are satisfied 23 | with the transactions we can write them to our ledger file by 24 | running `ledger -f ledger.dat --date-format "01/02/06" import MasterCard transactions.csv >> ledger.dat` 25 | -------------------------------------------------------------------------------- /ledger/book/src/02_Print.md: -------------------------------------------------------------------------------- 1 | # Print 2 | 3 | You can print your ledger file in a consistent format. Useful if you want all 4 | transactions to be in a consistent format and your file to always be ordered by 5 | date. 6 | 7 | Run `ledger -f ledger.dat print` 8 | 9 | `$ ledger -f ledger.dat print` 10 | 11 | You can also use this if your splitting off transactions into separate files by 12 | date range, or account. 13 | 14 | All 2021 transactions for example `ledger -f ledger.dat -b "2021/01/01" -e "2022/01/01" print` 15 | 16 | `$ ledger -f ledger.dat -b "2021/01/01" -e "2022/01/01" print` 17 | -------------------------------------------------------------------------------- /ledger/book/src/02_Register.md: -------------------------------------------------------------------------------- 1 | # Register 2 | 3 | Run `ledger -f ledger.dat reg` to see all transactions in register format. 4 | Since we aren't specifying a specific account, we will get all postings for 5 | all transactions and the running total will sum to zero, as all transactions 6 | balance. 7 | 8 | `$ ledger -f ledger.dat reg` 9 | 10 | ## Payee 11 | 12 | Let's see how much money we've spend at the "Grocery Store" each month. Also, 13 | to keep from seeing every posting, we are going to specify that we only want to 14 | see postings in the "Expenses" accounts. This will allow us to easily see a 15 | running total in the last column. 16 | 17 | Run `ledger -f ledger.dat reg --payee "Grocery Store" --period Monthly Expenses` 18 | 19 | `$ ledger -f ledger.dat reg --payee "Grocery Store" --period Monthly Expenses` 20 | 21 | ## Accounts 22 | 23 | Let's track down all the times we used our Credit Card. 24 | 25 | Run `ledger -f ledger.dat reg MasterCard` 26 | 27 | `$ ledger -f ledger.dat reg MasterCard` 28 | -------------------------------------------------------------------------------- /ledger/book/src/02_Stats.md: -------------------------------------------------------------------------------- 1 | # Stats 2 | 3 | A nice little summary of various ledger stats is available. 4 | 5 | Run `ledger -f ledger.dat stats` 6 | 7 | `$ ledger -f ledger.dat stats` 8 | -------------------------------------------------------------------------------- /ledger/book/src/Editing_VimPlugin.md: -------------------------------------------------------------------------------- 1 | # Editing in Vim 2 | 3 | A vim plugin is provided to apply syntax highlighting and account 4 | autocomplete when editing. Install the **vim-ledger** plugin. 5 | 6 | Below is the result of `:set filetype=ledger` in vim. 7 | 8 | ![vim syntax screenshot](consoleshots/vimsyn.png) 9 | 10 | The plugin can also do folding, try `:set foldmethod=syntax` 11 | 12 | ![vim folding screenshot](consoleshots/vimfold.png) 13 | 14 | ## Format on Save 15 | 16 | In order to format on save, set your vim config to the following: 17 | 18 | ```vim 19 | let g:ledger_autofmt_bufwritepre = 1 20 | ``` 21 | -------------------------------------------------------------------------------- /ledger/book/src/Example.md: -------------------------------------------------------------------------------- 1 | # Example File 2 | 3 | To make following along and running the commands easier, you can use the 4 | transactions below as your *ledger.dat* file. 5 | 6 | `$ cat ledger.dat` 7 | 8 | -------------------------------------------------------------------------------- /ledger/book/src/Introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Plain Text Accounting is the practice of maintaining an accounting ledger in a 4 | format that values human readability, accountant auditability, and future file 5 | accessibility. The ecosystem of PTA tools includes programs 6 | which enable recording of purchases and transfers and investments, 7 | and performing analysis to produce registers, balance sheets, profit and loss 8 | statements, and lots of other reports. 9 | 10 | The core tools of the Plain Text Accounting ecosystem is a workflow familiar 11 | to software developers who prefer command line tools, and text based file 12 | formats. 13 | 14 | This is a guide to plain text accounting in Ledger. 15 | 16 | -------------------------------------------------------------------------------- /ledger/book/src/LICENSE.md: -------------------------------------------------------------------------------- 1 | **License** 2 | 3 | © 2022 Chris Howey 4 | 5 | This work is licensed under Creative Commons BY-NC-SA 4.0. 6 | 7 | To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0. 8 | 9 | **Your rights under this license** 10 | 11 | You are free to: 12 | 13 | * Share — copy and redistribute the material in any medium or format 14 | * Adapt — remix, transform, and build upon the material 15 | 16 | The licensor cannot revoke these freedoms as long as you follow the license terms. 17 | 18 | Under the following terms: 19 | 20 | * Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. 21 | * NonCommercial — You may not use the material for commercial purposes. 22 | * ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original. 23 | 24 | No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. 25 | 26 | **Notices** 27 | 28 | You do not have to comply with the license for elements of the material in the public domain or where your use is permitted by an applicable exception or limitation. 29 | 30 | No warranties are given. The license may not give you all of the permissions necessary for your intended use. For example, other rights such as publicity, privacy, or moral rights may limit how you use the material. 31 | 32 | -------------------------------------------------------------------------------- /ledger/book/src/Performance.md: -------------------------------------------------------------------------------- 1 | # Performance 2 | 3 | Comparison between various ledger-like applications: 4 | 5 | - ledger-go 6 | - [ledger-cli](https://ledger-cli.org) 7 | - [hledger](https://hledger.org) 8 | 9 | ## Stats 10 | 11 | | Command | Mean | Min | Max | Relative | 12 | |:---|---:|---:|---:|---:| 13 | | `ledger-go stats` | 16.9ms ± 700µs | 15.4ms | 19.4ms | 1.00 | 14 | | `ledger-cli stats` | 139.3ms ± 1.8ms | 136ms | 145.5ms | 8.23 ± 0.40 | 15 | | `hledger stats` | 1.5835s ± 22.7ms | 1.5659s | 1.6467s | 93.49 ± 4.54 | 16 | 17 | ## Balance 18 | 19 | | Command | Mean | Min | Max | Relative | 20 | |:---|---:|---:|---:|---:| 21 | | `ledger-go bal` | 16.2ms ± 800µs | 15.1ms | 18.8ms | 1.00 | 22 | | `ledger-cli bal` | 149.5ms ± 2.1ms | 147.5ms | 157.4ms | 9.19 ± 0.48 | 23 | | `hledger bal` | 1.5783s ± 7.7ms | 1.5656s | 1.5877s | 97.01 ± 4.86 | 24 | 25 | ## Register 26 | 27 | | Command | Mean | Min | Max | Relative | 28 | |:---|---:|---:|---:|---:| 29 | | `ledger-go reg` | 29ms ± 900µs | 27.2ms | 31.4ms | 1.00 | 30 | | `ledger-cli reg` | 1.9186s ± 17.7ms | 1.8879s | 1.9468s | 65.96 ± 2.20 | 31 | | `hledger reg` | 2.2997s ± 14.6ms | 2.2761s | 2.3275s | 79.06 ± 2.58 | 32 | 33 | ## Print 34 | 35 | | Command | Mean | Min | Max | Relative | 36 | |:---|---:|---:|---:|---:| 37 | | `ledger-go print` | 25.1ms ± 1.5ms | 22ms | 29.2ms | 1.00 | 38 | | `ledger-cli print` | 281.7ms ± 5.7ms | 275.6ms | 296.1ms | 11.20 ± 0.71 | 39 | | `hledger print` | 1.8827s ± 15.1ms | 1.8546s | 1.905s | 74.83 ± 4.52 | 40 | 41 | -------------------------------------------------------------------------------- /ledger/book/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Introduction](./Introduction.md) 4 | 5 | # Setup 6 | 7 | - [Installation](./01_01_Installation.md) 8 | - [File Format](./01_02_FileFormat.md) 9 | - [First Run](./01_03_RunningLedger.md) 10 | 11 | # CLI Commands 12 | - [Accounts](./02_Accounts.md) 13 | - [Balance](./02_Balance.md) 14 | - [Equity](./02_Equity.md) 15 | - [Import](./02_Import.md) 16 | - [Export](./02_Export.md) 17 | - [Print](./02_Print.md) 18 | - [Register](./02_Register.md) 19 | - [Stats](./02_Stats.md) 20 | 21 | # Web Interface 22 | - [Overview](./Web_Overview.md) 23 | - [Quickview](./Web_Quickview.md) 24 | - [General Ledger](./Web_GeneralLedger.md) 25 | - [Accounts](./Web_Accounts.md) 26 | - [Add Transaction](./Web_AddTransaction.md) 27 | - [Reports](./Web_Reports.md) 28 | - [Stock Portfolio](./Web_Portfolio.md) 29 | 30 | # Other 31 | - [Performance](./Performance.md) 32 | 33 | ----- 34 | 35 | [Example File](./Example.md) 36 | [Editing in Vim](./Editing_VimPlugin.md) 37 | [License](./LICENSE.md) 38 | -------------------------------------------------------------------------------- /ledger/book/src/Web_Accounts.md: -------------------------------------------------------------------------------- 1 | # Account page 2 | 3 | Click the link to an account shows the register of postings related to that 4 | account. 5 | 6 | ![account page](webshots/account.png) 7 | -------------------------------------------------------------------------------- /ledger/book/src/Web_AddTransaction.md: -------------------------------------------------------------------------------- 1 | # Add Transaction 2 | 3 | The web interface also has the ability to add transactions to the ledger file. 4 | 5 | ![add transaction](webshots/addtrans.png) 6 | 7 | -------------------------------------------------------------------------------- /ledger/book/src/Web_GeneralLedger.md: -------------------------------------------------------------------------------- 1 | # General Ledger 2 | 3 | The general ledger page shows all transactions in a table format. 4 | 5 | ![general ledger](webshots/general-ledger.png) 6 | -------------------------------------------------------------------------------- /ledger/book/src/Web_Overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | The web service included in `ledger` allows for the easy and quick viewing 4 | of financial data in a graphical format. 5 | 6 | The default pages show the accounts, and a "General Ledger" listing all the 7 | transactions. 8 | 9 | Start up the web interface with `ledger -f ledger.dat web` 10 | 11 | Open a browser to the default address of `http://localhost:8056/` 12 | 13 | You should see the following. 14 | 15 | ![accounts list](webshots/accounts.png) 16 | 17 | This lists all the accounts. 18 | 19 | -------------------------------------------------------------------------------- /ledger/book/src/Web_Portfolio.md: -------------------------------------------------------------------------------- 1 | # Portfolio 2 | 3 | The web service included in `ledger` allows tracking a portfolio of various 4 | holdings. Currently stocks, mutual funds, and crypto currencies are supported. 5 | 6 | Basically, you just create a portfolio configuration file where you match your 7 | accounts to commodities and the shares of the commodity the account represents. 8 | 9 | The example configuration shows what crypto currency holding may look like. 10 | 11 | `$ cat portfolio.toml` 12 | 13 | ## Crypto Holdings 14 | 15 | Portfolio view of holdings. 16 | 17 | ![crypto holdings portfolio](webshots/portfolio-crypto.png) 18 | 19 | ## Stocks and Mutual Funds 20 | 21 | Stock or Mutual Fund Quotes require API keys to services. 22 | 23 | `$ cat portfolio-stocks.toml` 24 | -------------------------------------------------------------------------------- /ledger/book/src/Web_Quickview.md: -------------------------------------------------------------------------------- 1 | # Quickview 2 | 3 | The main page can be configured to show only a 4 | selected subset of all accounts by specifying a *quickview* configuration file. 5 | 6 | Take the following example. 7 | 8 | `$ cat quickview.toml` 9 | 10 | Run it with `ledger -f ledger -q quickview.toml web` 11 | 12 | The new, more compact start screen should look like the following. 13 | 14 | ![quickview list](webshots/quickview.png) 15 | -------------------------------------------------------------------------------- /ledger/book/src/Web_Reports.md: -------------------------------------------------------------------------------- 1 | # Reports 2 | 3 | The web service included in `ledger` allows for the configuration of many types 4 | of different reports, charts, and calculations. 5 | 6 | Lets try an example configuration. 7 | 8 | `$ cat reports.toml` 9 | 10 | ## Expenses 11 | 12 | This is a pie chart showing the spending per Expense account. 13 | 14 | ![expenses pie chart](webshots/report-expenses.png) 15 | 16 | ## Savings 17 | 18 | This report calculates a pseudo account "Savings" based on *Income - Expenses* 19 | over time and shows how much money has been saved per month. 20 | 21 | ![savings bar chart](webshots/report-savings.png) 22 | 23 | ## Net Worth 24 | 25 | Graph Assets against Liabilities. 26 | 27 | ![net worth line chart](webshots/report-networth.png) 28 | 29 | -------------------------------------------------------------------------------- /ledger/book/src/consoleshots/vimfold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/consoleshots/vimfold.png -------------------------------------------------------------------------------- /ledger/book/src/consoleshots/vimsyn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/consoleshots/vimsyn.png -------------------------------------------------------------------------------- /ledger/book/src/ledger.dat: -------------------------------------------------------------------------------- 1 | 2021-12-01 Checking balance 2 | Assets:Bank:Checking 1000.00 3 | Equity:Opening Balances 4 | 5 | 2021-12-25 Buy some Crypto 6 | Assets:Bank:Checking 7 | Assets:Crypto:BTC 300 8 | Assets:Crypto:ETH 200 9 | 10 | 2021-12-05 Withdrawl 11 | Assets:Bank:Checking -100.00 12 | Assets:Cash:Wallet 13 | 14 | 2021-12-31 Employer 15 | Assets:Bank:Checking 2000 16 | Income:Salary 17 | 18 | 2022-01-15 Employer 19 | Assets:Bank:Checking 2000 20 | Income:Salary 21 | 22 | 2022-01-02 Dominoes Pizza HOUSTON TX 23 | Liabilities:MasterCard 24 | Expenses:Food:TakeOut 23.58 25 | 26 | 2021-12-22 Grocery Store 27 | Assets:Bank:Checking 28 | Expenses:Food:Groceries 222.0 29 | 30 | 2022-01-11 Panda Express 31 | Liabilities:MasterCard 32 | Expenses:Food:TakeOut 17.34 33 | 34 | 2022-01-09 Grocery Store 35 | Assets:Bank:Checking 36 | Expenses:Food:Groceries 120.0 37 | 38 | 2022-01-02 Grocery Store 39 | Assets:Bank:Checking 40 | Expenses:Food:Groceries 145.0 41 | 42 | 2022-01-02 Grocery Store 43 | Assets:Bank:Checking 44 | Expenses:Food:Groceries 180.0 45 | 46 | 2022-01-08 Half Price Books HOUSTON TX 47 | Liabilities:MasterCard 48 | Expenses:Books 20.0 49 | -------------------------------------------------------------------------------- /ledger/book/src/portfolio-stocks.toml: -------------------------------------------------------------------------------- 1 | # Used for "Stock" security_type -- see https://iexcloud.io/docs/api/ 2 | iex_token = "pk_tokenstring" 3 | 4 | # Used for "Fund" security_type -- see https://www.alphavantage.co/documentation 5 | av_token = "apikey" 6 | 7 | [[portfolio]] 8 | name = "Stock Holdings" 9 | show_dividends = true 10 | 11 | [[portfolio.stock]] 12 | name = "S&P 500" 13 | security_type = "Stock" 14 | section = "Holdings" 15 | ticker = "SPY" 16 | account = "Assets:Holdings:SPY" 17 | shares = 200.0 18 | 19 | [[portfolio.stock]] 20 | name = "Vanguard Growth" 21 | security_type = "Fund" 22 | section = "Holdings" 23 | ticker = "VASGX" 24 | account = "Assets:Holdings:VASGX" 25 | shares = 23.5 26 | 27 | -------------------------------------------------------------------------------- /ledger/book/src/portfolio.toml: -------------------------------------------------------------------------------- 1 | [[portfolio]] 2 | name = "Crypto Holdings" 3 | 4 | [[portfolio.stock]] 5 | name = "Bitcoin" 6 | security_type = "Crypto" 7 | section = "Crypto" 8 | ticker = "BTC-USD" 9 | account = "Assets:Crypto:BTC" 10 | shares = 0.009 11 | 12 | [[portfolio.stock]] 13 | name = "Etherium" 14 | security_type = "Crypto" 15 | section = "Crypto" 16 | ticker = "ETH-USD" 17 | account = "Assets:Crypto:ETH" 18 | shares = 0.1 19 | 20 | -------------------------------------------------------------------------------- /ledger/book/src/quickview.toml: -------------------------------------------------------------------------------- 1 | [[account]] 2 | name = "Assets:Bank:Checking" 3 | short_name = "Checking" 4 | 5 | [[account]] 6 | name = "Assets:Cash:Wallet" 7 | short_name = "Wallet" 8 | 9 | [[account]] 10 | name = "Liabilities:MasterCard" 11 | short_name = "Card" 12 | -------------------------------------------------------------------------------- /ledger/book/src/reports.toml: -------------------------------------------------------------------------------- 1 | [[report]] 2 | name = "PQ Expenses" 3 | chart = "pie" 4 | date_range = "Previous Quarter" 5 | accounts = [ "Expenses:*" ] 6 | 7 | [[report]] 8 | name = "PY Expenses" 9 | chart = "pie" 10 | date_range = "Previous Year" 11 | accounts = [ "Expenses:*" ] 12 | 13 | [[report]] 14 | name = "YTD Expenses" 15 | chart = "pie" 16 | date_range = "YTD" 17 | accounts = [ "Expenses:*" ] 18 | 19 | [[report]] 20 | name = "YTD My Monthly Savings" 21 | chart = "bar" 22 | date_range = "YTD" 23 | date_freq = "Monthly" 24 | accounts = [ "Income", "Expenses" ] 25 | 26 | [[report.calculated_account]] 27 | name = "Savings" 28 | 29 | [[report.calculated_account.account_operation]] 30 | name = "Income" 31 | operation = "+" 32 | 33 | [[report.calculated_account.account_operation]] 34 | name = "Expenses" 35 | operation = "-" 36 | 37 | [[report]] 38 | name = "AT Net Worth" 39 | chart = "line" 40 | date_range = "All Time" 41 | date_freq = "Quarterly" 42 | accounts = [ "Assets", "Liabilities" ] 43 | 44 | [[report]] 45 | name = "AT Yearly Income" 46 | chart = "bar" 47 | date_range = "All Time" 48 | date_freq = "Yearly" 49 | accounts = [ "Income" ] 50 | 51 | -------------------------------------------------------------------------------- /ledger/book/src/transactions.csv: -------------------------------------------------------------------------------- 1 | Transaction Date,Description,Amount 2 | 01/12/22,Dominoes Pizza HOUSTON TX,12.34 3 | 01/23/22,Dominoes Pizza PEARLAND TX,14.34 4 | 01/02/22,Half Price Books AUSTIN TX,5.24 5 | -------------------------------------------------------------------------------- /ledger/book/src/webshots/account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/webshots/account.png -------------------------------------------------------------------------------- /ledger/book/src/webshots/accounts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/webshots/accounts.png -------------------------------------------------------------------------------- /ledger/book/src/webshots/addtrans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/webshots/addtrans.png -------------------------------------------------------------------------------- /ledger/book/src/webshots/general-ledger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/webshots/general-ledger.png -------------------------------------------------------------------------------- /ledger/book/src/webshots/portfolio-crypto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/webshots/portfolio-crypto.png -------------------------------------------------------------------------------- /ledger/book/src/webshots/quickview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/webshots/quickview.png -------------------------------------------------------------------------------- /ledger/book/src/webshots/report-expenses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/webshots/report-expenses.png -------------------------------------------------------------------------------- /ledger/book/src/webshots/report-networth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/webshots/report-networth.png -------------------------------------------------------------------------------- /ledger/book/src/webshots/report-savings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/book/src/webshots/report-savings.png -------------------------------------------------------------------------------- /ledger/cmd/export.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // exportCmd represents the export command 11 | var exportCmd = &cobra.Command{ 12 | Aliases: []string{"exp"}, 13 | Use: "export [account-substring-filter]...", 14 | Short: "export to CSV", 15 | Run: func(_ *cobra.Command, args []string) { 16 | generalLedger, err := cliTransactions() 17 | if err != nil { 18 | log.Fatalln(err) 19 | } 20 | PrintCSV(generalLedger, args) 21 | }, 22 | } 23 | 24 | func init() { 25 | rootCmd.AddCommand(exportCmd) 26 | 27 | var startDate, endDate time.Time 28 | startDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local) 29 | endDate = time.Now().Add(1<<63 - 1) 30 | exportCmd.Flags().StringVarP(&startString, "begin-date", "b", startDate.Format(transactionDateFormat), "Begin date of transaction processing.") 31 | exportCmd.Flags().StringVarP(&endString, "end-date", "e", endDate.Format(transactionDateFormat), "End date of transaction processing.") 32 | exportCmd.Flags().StringVar(&payeeFilter, "payee", "", "Filter output to payees that contain this string.") 33 | exportCmd.Flags().StringVar(&fieldDelimiter, "delimiter", ",", "Field delimiter.") 34 | } 35 | -------------------------------------------------------------------------------- /ledger/cmd/financialQuotes.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | "encoding/json" 7 | "errors" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/patrickmn/go-cache" 14 | "golang.org/x/time/rate" 15 | ) 16 | 17 | // Alpha Vantage allows 5 per minute, we do 4 per minute 18 | var avLimiter *rate.Limiter = rate.NewLimiter(rate.Every(time.Minute/4), 1) 19 | 20 | type avQuote struct { 21 | Symbol string 22 | Open float64 23 | PreviousClose float64 24 | Last float64 25 | } 26 | 27 | // Alpha Vantage allows 500 requests per day. Since we don't care about realtime 28 | // values, we cache results for 24 hours. 29 | var avqCache *cache.Cache = cache.New(time.Hour*24, time.Hour) 30 | 31 | // https://www.alphavantage.co/documentation/#latestprice 32 | func fundQuote(symbol string) (quote avQuote, err error) { 33 | if avq, found := avqCache.Get(symbol); found { 34 | return avq.(avQuote), nil 35 | } 36 | 37 | go func() { 38 | avLimiter.Wait(context.Background()) 39 | 40 | req, rerr := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://alphavantage.co/query?function=GLOBAL_QUOTE&symbol="+symbol+"&datatype=csv&apikey="+portfolioConfigData.AVToken, http.NoBody) 41 | if rerr != nil { 42 | return 43 | } 44 | resp, herr := http.DefaultClient.Do(req) 45 | if herr != nil { 46 | return 47 | } 48 | defer resp.Body.Close() 49 | cr := csv.NewReader(resp.Body) 50 | 51 | recs, cerr := cr.ReadAll() 52 | if cerr != nil { 53 | return 54 | } 55 | // symbol,open,high,low,price,volume,latestDay,previousClose,change,changePercent 56 | if len(recs) != 2 || len(recs[0]) != 10 { 57 | return 58 | } 59 | 60 | quote.Symbol = recs[1][0] 61 | quote.Open, _ = strconv.ParseFloat(recs[1][1], 64) 62 | quote.Last, _ = strconv.ParseFloat(recs[1][4], 64) 63 | quote.PreviousClose, _ = strconv.ParseFloat(recs[1][7], 64) 64 | 65 | avqCache.Add(symbol, quote, cache.DefaultExpiration) 66 | }() 67 | 68 | return quote, errors.New("not cached") 69 | } 70 | 71 | type gdaxQuote struct { 72 | Volume string `json:"volume"` 73 | PreviousClose float64 `json:"open,string"` 74 | Last float64 `json:"last,string"` 75 | } 76 | 77 | // https://docs.pro.coinbase.com/ 78 | func cryptoQuote(symbol string) (quote gdaxQuote, err error) { 79 | req, rerr := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.pro.coinbase.com/products/"+symbol+"/stats", http.NoBody) 80 | if rerr != nil { 81 | return 82 | } 83 | resp, herr := http.DefaultClient.Do(req) 84 | if herr != nil { 85 | return quote, herr 86 | } 87 | defer resp.Body.Close() 88 | dec := json.NewDecoder(resp.Body) 89 | derr := dec.Decode("e) 90 | if derr != nil { 91 | return quote, derr 92 | } 93 | if quote.Volume == "" { 94 | return quote, errors.New("Unable to find data for symbol " + symbol) 95 | } 96 | return quote, nil 97 | } 98 | 99 | // Alpha Vantage allows 500 requests per day. Since we don't care about realtime 100 | // values, we cache results for 24 hours. 101 | var avdCache *cache.Cache = cache.New(time.Hour*24, time.Hour) 102 | 103 | // https://www.alphavantage.co/documentation/#weeklyadj 104 | func fundAnnualDividends(symbol string) float64 { 105 | if div, found := avdCache.Get(symbol); found { 106 | return div.(float64) 107 | } 108 | 109 | go func() { 110 | avLimiter.Wait(context.Background()) 111 | 112 | req, rerr := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://www.alphavantage.co/query?function=TIME_SERIES_WEEKLY_ADJUSTED&datatype=csv&symbol="+symbol+"&apikey="+portfolioConfigData.AVToken, http.NoBody) 113 | if rerr != nil { 114 | return 115 | } 116 | resp, herr := http.DefaultClient.Do(req) 117 | if herr != nil { 118 | return 119 | } 120 | defer resp.Body.Close() 121 | cr := csv.NewReader(resp.Body) 122 | recs, cerr := cr.ReadAll() 123 | if cerr != nil { 124 | return 125 | } 126 | divIdx := -1 127 | if len(recs) < 2 { 128 | return 129 | } 130 | 131 | for i := range recs[0] { 132 | if strings.Contains(recs[0][i], "dividend") { 133 | divIdx = i 134 | } 135 | } 136 | 137 | if divIdx < 0 { 138 | return 139 | } 140 | 141 | yearAgo := time.Now().AddDate(-1, 0, 0).Format(time.DateOnly) 142 | 143 | var amount float64 144 | for _, rec := range recs[1:] { 145 | if div, derr := strconv.ParseFloat(rec[divIdx], 64); rec[0] > yearAgo && derr == nil { 146 | amount += div 147 | } 148 | } 149 | 150 | avdCache.Add(symbol, amount, cache.DefaultExpiration) 151 | }() 152 | 153 | return 0 154 | } 155 | -------------------------------------------------------------------------------- /ledger/cmd/import.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "math" 7 | "os" 8 | "strings" 9 | "time" 10 | "unicode/utf8" 11 | 12 | "github.com/howeyc/ledger" 13 | "github.com/howeyc/ledger/decimal" 14 | "github.com/jbrukh/bayesian" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | var csvDateFormat string 19 | var negateAmount bool 20 | var allowMatching bool 21 | var fieldDelimiter string 22 | var scaleFactor float64 23 | 24 | // importCmd represents the import command 25 | var importCmd = &cobra.Command{ 26 | Use: "import ", 27 | Args: cobra.ExactArgs(2), 28 | Short: "Import transactions from csv to ledger format", 29 | Run: func(_ *cobra.Command, args []string) { 30 | var accountSubstring, csvFileName string 31 | accountSubstring = args[0] 32 | csvFileName = args[1] 33 | 34 | decScale := decimal.NewFromFloat(scaleFactor) 35 | 36 | csvFileReader, err := os.Open(csvFileName) 37 | if err != nil { 38 | fmt.Println("CSV: ", err) 39 | return 40 | } 41 | defer csvFileReader.Close() 42 | 43 | generalLedger, parseError := ledger.ParseLedgerFile(ledgerFilePath) 44 | if parseError != nil { 45 | fmt.Printf("%s:%s\n", ledgerFilePath, parseError.Error()) 46 | return 47 | } 48 | 49 | var matchingAccount string 50 | matchingAccounts := ledger.GetBalances(generalLedger, []string{accountSubstring}) 51 | if len(matchingAccounts) < 1 { 52 | fmt.Println("Unable to find matching account.") 53 | return 54 | } 55 | for _, m := range matchingAccounts { 56 | if strings.EqualFold(m.Name, accountSubstring) { 57 | matchingAccount = m.Name 58 | break 59 | } 60 | } 61 | if matchingAccount == "" { 62 | matchingAccount = matchingAccounts[len(matchingAccounts)-1].Name 63 | } 64 | 65 | allAccounts := ledger.GetBalances(generalLedger, []string{}) 66 | 67 | csvReader := csv.NewReader(csvFileReader) 68 | csvReader.Comma, _ = utf8.DecodeRuneInString(fieldDelimiter) 69 | csvRecords, cerr := csvReader.ReadAll() 70 | if cerr != nil { 71 | fmt.Println("CSV parse error:", cerr.Error()) 72 | return 73 | } 74 | 75 | classes := make([]bayesian.Class, len(allAccounts)) 76 | for i, bal := range allAccounts { 77 | classes[i] = bayesian.Class(bal.Name) 78 | } 79 | classifier := bayesian.NewClassifier(classes...) 80 | for _, tran := range generalLedger { 81 | payeeWords := strings.Fields(tran.Payee) 82 | // learn accounts names (except matchingAccount) for transactions where matchingAccount is present 83 | learnName := false 84 | for _, accChange := range tran.AccountChanges { 85 | if accChange.Name == matchingAccount { 86 | learnName = true 87 | break 88 | } 89 | } 90 | if learnName { 91 | for _, accChange := range tran.AccountChanges { 92 | if accChange.Name != matchingAccount { 93 | classifier.Learn(payeeWords, bayesian.Class(accChange.Name)) 94 | } 95 | } 96 | } 97 | } 98 | 99 | // Find columns from header 100 | var dateColumn, payeeColumn, amountColumn, commentColumn int 101 | dateColumn, payeeColumn, amountColumn, commentColumn = -1, -1, -1, -1 102 | for fieldIndex, fieldName := range csvRecords[0] { 103 | fieldName = strings.ToLower(fieldName) 104 | if strings.Contains(fieldName, "date") { 105 | dateColumn = fieldIndex 106 | } else if strings.Contains(fieldName, "description") { 107 | payeeColumn = fieldIndex 108 | } else if strings.Contains(fieldName, "payee") { 109 | payeeColumn = fieldIndex 110 | } else if strings.Contains(fieldName, "amount") { 111 | amountColumn = fieldIndex 112 | } else if strings.Contains(fieldName, "expense") { 113 | amountColumn = fieldIndex 114 | } else if strings.Contains(fieldName, "note") { 115 | commentColumn = fieldIndex 116 | } else if strings.Contains(fieldName, "comment") { 117 | commentColumn = fieldIndex 118 | } 119 | } 120 | 121 | if dateColumn < 0 || payeeColumn < 0 || amountColumn < 0 { 122 | fmt.Println("Unable to find columns required from header field names.") 123 | return 124 | } 125 | 126 | expenseAccount := ledger.Account{Name: "unknown:unknown", Balance: decimal.Zero} 127 | csvAccount := ledger.Account{Name: matchingAccount, Balance: decimal.Zero} 128 | for _, record := range csvRecords[1:] { 129 | inputPayeeWords := strings.Fields(record[payeeColumn]) 130 | csvDate, _ := time.Parse(csvDateFormat, record[dateColumn]) 131 | if allowMatching || !existingTransaction(generalLedger, csvDate, record[payeeColumn]) { 132 | // Classify into expense account 133 | 134 | // Find the highest and second highest scores 135 | highScore1 := math.Inf(-1) 136 | highScore2 := math.Inf(-1) 137 | matchIdx := 0 138 | scores, _, _ := classifier.LogScores(inputPayeeWords) 139 | for j, score := range scores { 140 | if score > highScore1 { 141 | highScore2 = highScore1 142 | highScore1 = score 143 | matchIdx = j 144 | } 145 | } 146 | // If the difference between the highest and second highest scores is greater than 10 147 | // then it indicates that highscore is a high confidence match 148 | if highScore1-highScore2 > 10 { 149 | expenseAccount.Name = string(classifier.Classes[matchIdx]) 150 | } else { 151 | expenseAccount.Name = "unknown:unknown" 152 | } 153 | 154 | // Parse error, set to zero 155 | if dec, derr := decimal.NewFromString(record[amountColumn]); derr != nil { 156 | expenseAccount.Balance = decimal.Zero 157 | } else { 158 | expenseAccount.Balance = dec 159 | } 160 | 161 | // Negate amount if required 162 | if negateAmount { 163 | expenseAccount.Balance = expenseAccount.Balance.Neg() 164 | } 165 | 166 | // Apply scale 167 | expenseAccount.Balance = expenseAccount.Balance.Mul(decScale) 168 | 169 | // Csv amount is the negative of the expense amount 170 | csvAccount.Balance = expenseAccount.Balance.Neg() 171 | 172 | // Create valid transaction for print in ledger format 173 | trans := &ledger.Transaction{Date: csvDate, Payee: record[payeeColumn]} 174 | trans.AccountChanges = []ledger.Account{csvAccount, expenseAccount} 175 | 176 | // Comment 177 | if commentColumn >= 0 && record[commentColumn] != "" { 178 | trans.Comments = []string{";" + record[commentColumn]} 179 | } 180 | WriteTransaction(os.Stdout, trans, 80) 181 | } 182 | } 183 | 184 | }, 185 | } 186 | 187 | func init() { 188 | rootCmd.AddCommand(importCmd) 189 | 190 | importCmd.Flags().BoolVar(&negateAmount, "neg", false, "Negate amount column value.") 191 | importCmd.Flags().BoolVar(&allowMatching, "allow-matching", false, "Have output include imported transactions that\nmatch existing ledger transactions.") 192 | importCmd.Flags().Float64Var(&scaleFactor, "scale", 1.0, "Scale factor to multiply against every imported amount.") 193 | importCmd.Flags().StringVar(&csvDateFormat, "date-format", "01/02/2006", "Date format.") 194 | importCmd.Flags().StringVar(&fieldDelimiter, "delimiter", ",", "Field delimiter.") 195 | } 196 | 197 | func existingTransaction(generalLedger []*ledger.Transaction, transDate time.Time, payee string) bool { 198 | for _, trans := range generalLedger { 199 | if trans.Date == transDate && strings.TrimSpace(trans.Payee) == strings.TrimSpace(payee) { 200 | return true 201 | } 202 | } 203 | return false 204 | } 205 | -------------------------------------------------------------------------------- /ledger/cmd/internal/httpcompress/compress.go: -------------------------------------------------------------------------------- 1 | package httpcompress 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/andybalholm/brotli" 10 | ) 11 | 12 | // CompressResponseWriter is a Struct for manipulating io writer 13 | type CompressResponseWriter struct { 14 | io.Writer 15 | http.ResponseWriter 16 | } 17 | 18 | func (res CompressResponseWriter) Write(b []byte) (int, error) { 19 | if "" == res.Header().Get("Content-Type") { 20 | // If no content type, apply sniffing algorithm to un-gzipped body. 21 | res.Header().Set("Content-Type", http.DetectContentType(b)) 22 | } 23 | return res.Writer.Write(b) 24 | } 25 | 26 | // Middleware force - bool, whether or not to force Compression regardless of the sent headers. 27 | func Middleware(fn http.HandlerFunc, force bool) http.HandlerFunc { 28 | return func(res http.ResponseWriter, req *http.Request) { 29 | if !strings.Contains(req.Header.Get("Accept-Encoding"), "br") { 30 | if !strings.Contains(req.Header.Get("Accept-Encoding"), "gzip") && !force { 31 | fn(res, req) 32 | return 33 | } 34 | res.Header().Set("Vary", "Accept-Encoding") 35 | res.Header().Set("Content-Encoding", "gzip") 36 | gz := gzip.NewWriter(res) 37 | defer gz.Close() 38 | cw := CompressResponseWriter{Writer: gz, ResponseWriter: res} 39 | fn(cw, req) 40 | return 41 | } 42 | res.Header().Set("Vary", "Accept-Encoding") 43 | res.Header().Set("Content-Encoding", "br") 44 | br := brotli.NewWriter(res) 45 | defer br.Close() 46 | cw := CompressResponseWriter{Writer: br, ResponseWriter: res} 47 | fn(cw, req) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ledger/cmd/internal/pdr/grammar.peg: -------------------------------------------------------------------------------- 1 | package pdr 2 | 3 | import "time" 4 | 5 | type parser Peg { 6 | currentTime time.Time 7 | start time.Time 8 | end time.Time 9 | number int 10 | } 11 | 12 | Query 13 | <- Expr EOF 14 | 15 | Expr 16 | <- NOW 17 | / PAST 18 | / FUTURE 19 | / EVERYTHING 20 | 21 | NOW 22 | <- (CURRENT YEARS / YEARS TODATE / 'ytd' _) 23 | { 24 | p.start, p.end = boundsYear(p.currentTime) 25 | } 26 | / (CURRENT QUARTERS / QUARTERS TODATE / 'qtd' _) 27 | { 28 | p.start, p.end = boundsQuarter(p.currentTime) 29 | } 30 | / (CURRENT MONTHS / MONTHS TODATE / 'mtd' _) 31 | { 32 | p.start, p.end = boundsMonth(p.currentTime) 33 | } 34 | 35 | PAST 36 | <- LAST Number? YEARS 37 | { 38 | p.start, p.end = boundsYear(p.currentTime) 39 | if p.number > 1 { 40 | p.start = p.start.AddDate(-1*(p.number-1), 0, 0) 41 | } else { 42 | p.end = p.start 43 | p.start = p.start.AddDate(-1, 0, 0) 44 | } 45 | } 46 | / LAST Number? QUARTERS 47 | { 48 | p.start, p.end = boundsQuarter(p.currentTime) 49 | if p.number > 1 { 50 | p.start = p.start.AddDate(0, -3*(p.number-1), 0) 51 | } else { 52 | p.end = p.start 53 | p.start = p.start.AddDate(0,-3,0) 54 | } 55 | } 56 | / LAST Number? MONTHS 57 | { 58 | p.start, p.end = boundsMonth(p.currentTime) 59 | if p.number > 1 { 60 | p.start = p.start.AddDate(0, -1*(p.number-1), 0) 61 | } else { 62 | p.end = p.start 63 | p.start = p.start.AddDate(0, -1, 0) 64 | } 65 | } 66 | / LAST Number? WEEKS 67 | { 68 | p.start, p.end = boundsWeek(p.currentTime) 69 | if p.number > 1 { 70 | p.start = p.start.AddDate(0, 0, -7*(p.number-1)) 71 | } else { 72 | p.end = p.start 73 | p.start = p.start.AddDate(0, 0, -7) 74 | } 75 | } 76 | 77 | FUTURE 78 | <- NEXT Number? YEARS 79 | { 80 | p.start, p.end = boundsYear(p.currentTime) 81 | if p.number > 1 { 82 | p.end = p.start.AddDate(p.number, 0, 0) 83 | } else { 84 | p.start = p.end 85 | p.end = p.start.AddDate(1, 0, 0) 86 | } 87 | } 88 | / NEXT Number? QUARTERS 89 | { 90 | p.start, p.end = boundsQuarter(p.currentTime) 91 | if p.number > 1 { 92 | p.end = p.start.AddDate(0,3*(p.number),0) 93 | } else { 94 | p.start = p.start.AddDate(0,3,0) 95 | p.end = p.start.AddDate(0,3,0) 96 | } 97 | } 98 | / NEXT Number? MONTHS 99 | { 100 | p.start, p.end = boundsMonth(p.currentTime) 101 | if p.number > 1 { 102 | p.end = p.start.AddDate(0, p.number, 0) 103 | } else { 104 | p.start = p.end 105 | p.end = p.start.AddDate(0, 1, 0) 106 | } 107 | } 108 | / NEXT Number? WEEKS 109 | { 110 | p.start, p.end = boundsWeek(p.currentTime) 111 | if p.number > 1 { 112 | p.end = p.start.AddDate(0, 0, 7*p.number) 113 | } else { 114 | p.start = p.end 115 | p.end = p.start.AddDate(0, 0, 7) 116 | } 117 | } 118 | 119 | EVERYTHING 120 | <- ('all time' / 'forever' / 'everything') _ 121 | { 122 | p.start = time.Time{} 123 | p.end = p.currentTime.Add(1<<63 -1) 124 | } 125 | 126 | Number 127 | <- < [0-9]+ > _ { n, _ := strconv.Atoi(text); p.number = n} 128 | / 'one' _ { p.number = 1 } 129 | / 'two' _ { p.number = 2 } 130 | / 'three' _ { p.number = 3 } 131 | / 'four' _ { p.number = 4 } 132 | / 'five' _ { p.number = 5 } 133 | / 'six' _ { p.number = 6 } 134 | / 'seven' _ { p.number = 7 } 135 | / 'eight' _ { p.number = 8 } 136 | / 'nine' _ { p.number = 9 } 137 | / 'ten' _ { p.number = 10 } 138 | / 'eleven' _ { p.number = 11 } 139 | / 'twelve' _ { p.number = 12 } 140 | / 'thirteen' _ { p.number = 13 } 141 | / 'fourteen' _ { p.number = 14 } 142 | / 'fifteen' _ { p.number = 15 } 143 | / 'sixteen' _ { p.number = 16 } 144 | / 'seventeen' _ { p.number = 17 } 145 | / 'eightteen' _ { p.number = 18 } 146 | / 'nineteen' _ { p.number = 19 } 147 | / 'twenty' _ { p.number = 20 } 148 | 149 | YEARS <- 'year' 's'? _ 150 | QUARTERS <- 'quarter' 's'? _ 151 | MONTHS <- 'month' 's'? _ 152 | WEEKS <- 'week' 's'? _ 153 | 154 | LAST <- ('last' / 'previous' / 'past') _ 155 | CURRENT <- 'current' _ 156 | TODATE <- 'to date' _ 157 | NEXT <- ('next') _ 158 | 159 | _ 160 | <- Whitespace* 161 | 162 | Whitespace 163 | <- ' ' / '\t' / EOL 164 | 165 | EOL 166 | <- '\r\n' / '\n' / '\r' 167 | 168 | EOF 169 | <- !. 170 | -------------------------------------------------------------------------------- /ledger/cmd/internal/pdr/pdr.go: -------------------------------------------------------------------------------- 1 | //go:generate peg -inline -switch grammar.peg 2 | 3 | // Package pdr parses date range as string 4 | // Uses pointlander/peg 5 | package pdr 6 | 7 | import ( 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // ParseRange parses a human readable specified time range into two dates containing that range. 13 | // start is included in the range, end is just beyond the range. So the returned dates/times are 14 | // such that the range is start <= RANGE < end. 15 | // 16 | // Also, ranges with a numeric factor are returned as if the current date is a part of the range 17 | // specified. For instance, the "last two months" is the previous month and the current month. 18 | // However, range without numeric factor excludes current month. Specifying "last month" returns 19 | // just the range for that month. 20 | func ParseRange(s string, baseTime time.Time) (start, end time.Time, err error) { 21 | p := &parser{ 22 | Buffer: strings.ToLower(s), 23 | currentTime: baseTime, 24 | } 25 | 26 | p.Init() 27 | 28 | if err := p.Parse(); err != nil { 29 | return time.Time{}, time.Time{}, err 30 | } 31 | 32 | p.Execute() 33 | return p.start, p.end, nil 34 | } 35 | 36 | func boundsWeek(t time.Time) (start, end time.Time) { 37 | sowDiff := t.Weekday() - time.Sunday 38 | start = time.Date(t.Year(), t.Month(), t.Day()-int(sowDiff), 0, 0, 0, 0, time.UTC) 39 | end = start.AddDate(0, 0, 7) 40 | return 41 | } 42 | 43 | func boundsMonth(t time.Time) (start, end time.Time) { 44 | start = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) 45 | end = start.AddDate(0, 1, 0) 46 | return 47 | } 48 | 49 | func boundsQuarter(t time.Time) (start, end time.Time) { 50 | switch t.Month() { 51 | case time.January, time.February, time.March: 52 | start = time.Date(t.Year(), time.January, 1, 0, 0, 0, 0, time.UTC) 53 | case time.April, time.May, time.June: 54 | start = time.Date(t.Year(), time.April, 1, 0, 0, 0, 0, time.UTC) 55 | case time.July, time.August, time.September: 56 | start = time.Date(t.Year(), time.July, 1, 0, 0, 0, 0, time.UTC) 57 | case time.October, time.November, time.December: 58 | start = time.Date(t.Year(), time.October, 1, 0, 0, 0, 0, time.UTC) 59 | } 60 | end = start.AddDate(0, 3, 0) 61 | return 62 | } 63 | 64 | func boundsYear(t time.Time) (start, end time.Time) { 65 | start = time.Date(t.Year(), time.January, 1, 0, 0, 0, 0, time.UTC) 66 | end = start.AddDate(1, 0, 0) 67 | return 68 | } 69 | -------------------------------------------------------------------------------- /ledger/cmd/internal/pdr/pdr_test.go: -------------------------------------------------------------------------------- 1 | package pdr 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | // 2019-11-25 9 | var baseTime = time.Unix(1574687238, 0).UTC() 10 | 11 | var testCases = []struct { 12 | Input string 13 | Start, End string 14 | }{ 15 | {"current month", "2019-11-01", "2019-12-01"}, 16 | {"month to date", "2019-11-01", "2019-12-01"}, 17 | {"last month", "2019-10-01", "2019-11-01"}, 18 | {"previous month", "2019-10-01", "2019-11-01"}, 19 | {"next month", "2019-12-01", "2020-01-01"}, 20 | 21 | {"current year", "2019-01-01", "2020-01-01"}, 22 | {"year to date", "2019-01-01", "2020-01-01"}, 23 | {"ytd", "2019-01-01", "2020-01-01"}, 24 | {"previous year", "2018-01-01", "2019-01-01"}, 25 | {"last 3 years", "2017-01-01", "2020-01-01"}, 26 | {"next year", "2020-01-01", "2021-01-01"}, 27 | {"next two years", "2019-01-01", "2021-01-01"}, 28 | {"next 5 years", "2019-01-01", "2024-01-01"}, 29 | {"next three months", "2019-11-01", "2020-02-01"}, 30 | 31 | {"current quarter", "2019-10-01", "2020-01-01"}, 32 | {"next quarter", "2020-01-01", "2020-04-01"}, 33 | {"next two quarters", "2019-10-01", "2020-04-01"}, 34 | 35 | {"last week", "2019-11-17", "2019-11-24"}, 36 | {"last 2 weeks", "2019-11-17", "2019-12-01"}, 37 | {"next 4 weeks", "2019-11-24", "2019-12-22"}, 38 | 39 | {"last 2 months", "2019-10-01", "2019-12-01"}, 40 | {"last quarter", "2019-07-01", "2019-10-01"}, 41 | {"last two quarters", "2019-07-01", "2020-01-01"}, 42 | {"last three quarters", "2019-04-01", "2020-01-01"}, 43 | 44 | // Adding max duration to baseTime 45 | {"all time", "0001-01-01", "2312-03-06"}, 46 | {"forever", "0001-01-01", "2312-03-06"}, 47 | } 48 | 49 | func TestParse(t *testing.T) { 50 | for _, c := range testCases { 51 | s, e, err := ParseRange(c.Input, baseTime) 52 | gotStart := s.Format(time.DateOnly) 53 | gotEnd := e.Format(time.DateOnly) 54 | if gotStart != c.Start { 55 | t.Fatalf("input %v, expected start: %v, got: %v", c.Input, c.Start, gotStart) 56 | } 57 | if gotEnd != c.End { 58 | t.Fatalf("input %v, expected end: %v, got: %v", c.Input, c.End, gotEnd) 59 | } 60 | if err != nil { 61 | t.Fatalf("input %v, unexpected error: %v", c.Input, err) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ledger/cmd/lint.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/howeyc/ledger" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // lintCmd represents the lint command 11 | var lintCmd = &cobra.Command{ 12 | Use: "lint", 13 | Short: "Check ledger for errors", 14 | Run: func(_ *cobra.Command, _ []string) { 15 | _, lerr := ledger.ParseLedgerFile(ledgerFilePath) 16 | if lerr != nil { 17 | fmt.Println("Ledger: ", lerr) 18 | } 19 | }, 20 | } 21 | 22 | func init() { 23 | rootCmd.AddCommand(lintCmd) 24 | } 25 | -------------------------------------------------------------------------------- /ledger/cmd/printAccounts.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | "github.com/howeyc/ledger" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var accountLeavesOnly bool 14 | var accountMatchDepth bool 15 | 16 | // accountsCmd represents the accounts command 17 | var accountsCmd = &cobra.Command{ 18 | Use: "accounts [account-substring-filter]...", 19 | Short: "Print accounts list", 20 | Run: func(_ *cobra.Command, args []string) { 21 | generalLedger, err := cliTransactions() 22 | if err != nil { 23 | log.Fatalln(err) 24 | } 25 | 26 | if accountMatchDepth && len(args) != 1 { 27 | log.Fatalln("account depth matches with one filter") 28 | } 29 | 30 | var filterDepth int 31 | if accountMatchDepth { 32 | filterDepth = strings.Count(args[0], ":") 33 | } 34 | 35 | balances := ledger.GetBalances(generalLedger, args) 36 | 37 | children := make(map[string]int) 38 | for _, acc := range balances { 39 | if i := strings.LastIndex(acc.Name, ":"); i >= 0 { 40 | children[acc.Name[:i]]++ 41 | } 42 | } 43 | 44 | for _, acc := range balances { 45 | match := true 46 | if accountLeavesOnly && children[acc.Name] > 0 { 47 | match = false 48 | } 49 | if accountMatchDepth && filterDepth != strings.Count(acc.Name, ":") { 50 | match = false 51 | } 52 | if match { 53 | fmt.Println(acc.Name) 54 | } 55 | } 56 | }, 57 | } 58 | 59 | func init() { 60 | rootCmd.AddCommand(accountsCmd) 61 | 62 | var startDate, endDate time.Time 63 | startDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local) 64 | endDate = time.Now().Add(1<<63 - 1) 65 | accountsCmd.Flags().StringVarP(&startString, "begin-date", "b", startDate.Format(transactionDateFormat), "Begin date of transaction processing.") 66 | accountsCmd.Flags().StringVarP(&endString, "end-date", "e", endDate.Format(transactionDateFormat), "End date of transaction processing.") 67 | accountsCmd.Flags().BoolVarP(&accountLeavesOnly, "leaves-only", "l", false, "Only show most-depth accounts") 68 | accountsCmd.Flags().BoolVarP(&accountMatchDepth, "match-depth", "m", false, "Show accounts with same depth as filter") 69 | } 70 | -------------------------------------------------------------------------------- /ledger/cmd/printBalance.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | "github.com/howeyc/ledger" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // balanceCmd represents the balance command 14 | var balanceCmd = &cobra.Command{ 15 | Aliases: []string{"bal"}, 16 | Use: "balance [account-substring-filter]...", 17 | Short: "Print account balances", 18 | Run: func(_ *cobra.Command, args []string) { 19 | generalLedger, err := cliTransactions() 20 | if err != nil { 21 | log.Fatalln(err) 22 | } 23 | if period == "" { 24 | PrintBalances(ledger.GetBalances(generalLedger, args), showEmptyAccounts, transactionDepth, columnWidth) 25 | } else { 26 | lperiod := ledger.Period(strings.Title(period)) 27 | rtrans := ledger.TransactionsByPeriod(generalLedger, lperiod) 28 | for rIdx, rt := range rtrans { 29 | balances := ledger.GetBalances(rt.Transactions, args) 30 | if len(balances) < 1 { 31 | continue 32 | } 33 | 34 | if rIdx > 0 { 35 | fmt.Println("") 36 | fmt.Println(strings.Repeat("=", columnWidth)) 37 | } 38 | fmt.Println(rt.Start.Format(transactionDateFormat), "-", rt.End.Format(transactionDateFormat)) 39 | fmt.Println(strings.Repeat("=", columnWidth)) 40 | PrintBalances(balances, showEmptyAccounts, transactionDepth, columnWidth) 41 | } 42 | } 43 | }, 44 | } 45 | 46 | func init() { 47 | rootCmd.AddCommand(balanceCmd) 48 | 49 | var startDate, endDate time.Time 50 | startDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local) 51 | endDate = time.Now().Add(1<<63 - 1) 52 | balanceCmd.Flags().StringVarP(&startString, "begin-date", "b", startDate.Format(transactionDateFormat), "Begin date of transaction processing.") 53 | balanceCmd.Flags().StringVarP(&endString, "end-date", "e", endDate.Format(transactionDateFormat), "End date of transaction processing.") 54 | balanceCmd.Flags().StringVar(&payeeFilter, "payee", "", "Filter output to payees that contain this string.") 55 | balanceCmd.Flags().IntVar(&columnWidth, "columns", 80, "Set a column width for output.") 56 | balanceCmd.Flags().BoolVar(&columnWide, "wide", false, "Wide output (use terminal width).") 57 | 58 | balanceCmd.Flags().StringVar(&period, "period", "", "Split output into periods (Monthly,Quarterly,SemiYearly,Yearly).") 59 | balanceCmd.Flags().BoolVar(&showEmptyAccounts, "empty", false, "Show empty (zero balance) accounts.") 60 | balanceCmd.Flags().IntVar(&transactionDepth, "depth", -1, "Depth of transaction output (balance).") 61 | } 62 | -------------------------------------------------------------------------------- /ledger/cmd/printEquity.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "slices" 7 | "strings" 8 | "time" 9 | 10 | "github.com/howeyc/ledger" 11 | "github.com/howeyc/ledger/decimal" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // equityCmd represents the equity command 16 | var equityCmd = &cobra.Command{ 17 | Use: "equity [account-substring-filter]...", 18 | Short: "Print account equity as transaction", 19 | Run: func(_ *cobra.Command, args []string) { 20 | generalLedger, err := cliTransactions() 21 | if err != nil { 22 | log.Fatalln(err) 23 | } 24 | 25 | var trans ledger.Transaction 26 | trans.Payee = "Opening Balances" 27 | trans.Date = time.Now() 28 | if len(generalLedger) > 0 { 29 | trans.Date = generalLedger[len(generalLedger)-1].Date 30 | } 31 | 32 | filterArr := args 33 | balances := make(map[string]decimal.Decimal) 34 | for _, trans := range generalLedger { 35 | for _, accChange := range trans.AccountChanges { 36 | inFilter := len(filterArr) == 0 37 | for _, filter := range filterArr { 38 | if strings.Contains(accChange.Name, filter) { 39 | inFilter = true 40 | } 41 | } 42 | if inFilter { 43 | if decNum, ok := balances[accChange.Name]; !ok { 44 | balances[accChange.Name] = accChange.Balance 45 | } else { 46 | balances[accChange.Name] = decNum.Add(accChange.Balance) 47 | } 48 | } 49 | } 50 | } 51 | 52 | eqBal := decimal.Zero 53 | for name, bal := range balances { 54 | if !bal.IsZero() { 55 | trans.AccountChanges = append(trans.AccountChanges, ledger.Account{ 56 | Name: name, 57 | Balance: bal, 58 | }) 59 | } 60 | eqBal = eqBal.Add(bal) 61 | } 62 | trans.AccountChanges = append(trans.AccountChanges, ledger.Account{ 63 | Name: "Equity", 64 | Balance: eqBal.Neg(), 65 | }) 66 | 67 | slices.SortFunc(trans.AccountChanges, func(a, b ledger.Account) int { 68 | return strings.Compare(a.Name, b.Name) 69 | }) 70 | 71 | WriteTransaction(os.Stdout, &trans, 80) 72 | }, 73 | } 74 | 75 | func init() { 76 | rootCmd.AddCommand(equityCmd) 77 | 78 | var startDate, endDate time.Time 79 | startDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local) 80 | endDate = time.Now().Add(1<<63 - 1) 81 | equityCmd.Flags().StringVarP(&startString, "begin-date", "b", startDate.Format(transactionDateFormat), "Begin date of transaction processing.") 82 | equityCmd.Flags().StringVarP(&endString, "end-date", "e", endDate.Format(transactionDateFormat), "End date of transaction processing.") 83 | } 84 | -------------------------------------------------------------------------------- /ledger/cmd/printRegister.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | "github.com/howeyc/ledger" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // registerCmd represents the register command 14 | var registerCmd = &cobra.Command{ 15 | Aliases: []string{"reg"}, 16 | Use: "register [account-substring-filter]...", 17 | Short: "Print register of transactions", 18 | Run: func(_ *cobra.Command, args []string) { 19 | generalLedger, err := cliTransactions() 20 | if err != nil { 21 | log.Fatalln(err) 22 | } 23 | if period == "" { 24 | PrintRegister(generalLedger, args, columnWidth) 25 | } else { 26 | lperiod := ledger.Period(strings.Title(period)) 27 | rtrans := ledger.TransactionsByPeriod(generalLedger, lperiod) 28 | for rIdx, rt := range rtrans { 29 | if len(rt.Transactions) < 1 { 30 | continue 31 | } 32 | 33 | if rIdx > 0 { 34 | fmt.Println(strings.Repeat("=", columnWidth)) 35 | } 36 | fmt.Println(rt.Start.Format(transactionDateFormat), "-", rt.End.Format(transactionDateFormat)) 37 | fmt.Println(strings.Repeat("=", columnWidth)) 38 | PrintRegister(rt.Transactions, args, columnWidth) 39 | } 40 | } 41 | }, 42 | } 43 | 44 | func init() { 45 | rootCmd.AddCommand(registerCmd) 46 | 47 | var startDate, endDate time.Time 48 | startDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local) 49 | endDate = time.Now().Add(1<<63 - 1) 50 | registerCmd.Flags().StringVarP(&startString, "begin-date", "b", startDate.Format(transactionDateFormat), "Begin date of transaction processing.") 51 | registerCmd.Flags().StringVarP(&endString, "end-date", "e", endDate.Format(transactionDateFormat), "End date of transaction processing.") 52 | registerCmd.Flags().StringVar(&payeeFilter, "payee", "", "Filter output to payees that contain this string.") 53 | registerCmd.Flags().IntVar(&columnWidth, "columns", 80, "Set a column width for output.") 54 | registerCmd.Flags().BoolVar(&columnWide, "wide", false, "Wide output (use terminal width).") 55 | 56 | registerCmd.Flags().StringVar(&period, "period", "", "Split output into periods (Monthly,Quarterly,SemiYearly,Yearly).") 57 | } 58 | -------------------------------------------------------------------------------- /ledger/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "runtime/pprof" 7 | 8 | cc "github.com/ivanpirog/coloredcobra" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var cpuprofile string 13 | var cpuf *os.File 14 | 15 | // rootCmd represents the base command when called without any subcommands 16 | var rootCmd = &cobra.Command{ 17 | Use: "ledger", 18 | Short: "Plain text accounting", 19 | PersistentPreRun: func(_ *cobra.Command, _ []string) { 20 | if cpuprofile != "" { 21 | var err error 22 | cpuf, err = os.Create(cpuprofile) 23 | if err != nil { 24 | log.Fatal("could not create CPU profile: ", err) 25 | } 26 | if err := pprof.StartCPUProfile(cpuf); err != nil { 27 | log.Fatal("could not start CPU profile: ", err) 28 | } 29 | } 30 | }, 31 | PersistentPostRun: func(_ *cobra.Command, _ []string) { 32 | if cpuprofile != "" { 33 | pprof.StopCPUProfile() 34 | cpuf.Close() 35 | } 36 | }, 37 | } 38 | 39 | // Execute adds all child commands to the root command and sets flags appropriately. 40 | // This is called by main.main(). It only needs to happen once to the rootCmd. 41 | func Execute() { 42 | cc.Init(&cc.Config{ 43 | RootCmd: rootCmd, 44 | Headings: cc.Magenta + cc.Bold + cc.Underline, 45 | Commands: cc.Red + cc.Bold, 46 | Aliases: cc.Bold + cc.Italic, 47 | CmdShortDescr: cc.White, 48 | Example: cc.Italic, 49 | ExecName: cc.Bold, 50 | Flags: cc.Yellow + cc.Bold, 51 | FlagsDescr: cc.White, 52 | FlagsDataType: cc.Italic + cc.Blue, 53 | NoExtraNewlines: true, 54 | }) 55 | cobra.CheckErr(rootCmd.Execute()) 56 | } 57 | 58 | var ledgerFilePath string 59 | 60 | func init() { 61 | cobra.OnInitialize(initConfig) 62 | 63 | ledgerFilePath = os.Getenv("LEDGER_FILE") 64 | 65 | rootCmd.PersistentFlags().StringVarP(&ledgerFilePath, "file", "f", ledgerFilePath, "ledger file (default is $LEDGER_FILE)") 66 | rootCmd.PersistentFlags().StringVarP(&cpuprofile, "prof", "", "", "write cpu profile to `file`") 67 | } 68 | 69 | // initConfig reads in config file and ENV variables if set. 70 | func initConfig() { 71 | } 72 | -------------------------------------------------------------------------------- /ledger/cmd/static/daterangepicker.css: -------------------------------------------------------------------------------- 1 | .daterangepicker { 2 | position: absolute; 3 | color: inherit; 4 | background-color: #fff; 5 | border-radius: 4px; 6 | border: 1px solid #ddd; 7 | width: 278px; 8 | max-width: none; 9 | padding: 0; 10 | margin-top: 7px; 11 | top: 100px; 12 | left: 20px; 13 | z-index: 3001; 14 | display: none; 15 | font-family: arial; 16 | font-size: 15px; 17 | line-height: 1em; 18 | } 19 | 20 | .daterangepicker:before, .daterangepicker:after { 21 | position: absolute; 22 | display: inline-block; 23 | border-bottom-color: rgba(0, 0, 0, 0.2); 24 | content: ''; 25 | } 26 | 27 | .daterangepicker:before { 28 | top: -7px; 29 | border-right: 7px solid transparent; 30 | border-left: 7px solid transparent; 31 | border-bottom: 7px solid #ccc; 32 | } 33 | 34 | .daterangepicker:after { 35 | top: -6px; 36 | border-right: 6px solid transparent; 37 | border-bottom: 6px solid #fff; 38 | border-left: 6px solid transparent; 39 | } 40 | 41 | .daterangepicker.opensleft:before { 42 | right: 9px; 43 | } 44 | 45 | .daterangepicker.opensleft:after { 46 | right: 10px; 47 | } 48 | 49 | .daterangepicker.openscenter:before { 50 | left: 0; 51 | right: 0; 52 | width: 0; 53 | margin-left: auto; 54 | margin-right: auto; 55 | } 56 | 57 | .daterangepicker.openscenter:after { 58 | left: 0; 59 | right: 0; 60 | width: 0; 61 | margin-left: auto; 62 | margin-right: auto; 63 | } 64 | 65 | .daterangepicker.opensright:before { 66 | left: 9px; 67 | } 68 | 69 | .daterangepicker.opensright:after { 70 | left: 10px; 71 | } 72 | 73 | .daterangepicker.drop-up { 74 | margin-top: -7px; 75 | } 76 | 77 | .daterangepicker.drop-up:before { 78 | top: initial; 79 | bottom: -7px; 80 | border-bottom: initial; 81 | border-top: 7px solid #ccc; 82 | } 83 | 84 | .daterangepicker.drop-up:after { 85 | top: initial; 86 | bottom: -6px; 87 | border-bottom: initial; 88 | border-top: 6px solid #fff; 89 | } 90 | 91 | .daterangepicker.single .daterangepicker .ranges, .daterangepicker.single .drp-calendar { 92 | float: none; 93 | } 94 | 95 | .daterangepicker.single .drp-selected { 96 | display: none; 97 | } 98 | 99 | .daterangepicker.show-calendar .drp-calendar { 100 | display: block; 101 | } 102 | 103 | .daterangepicker.show-calendar .drp-buttons { 104 | display: block; 105 | } 106 | 107 | .daterangepicker.auto-apply .drp-buttons { 108 | display: none; 109 | } 110 | 111 | .daterangepicker .drp-calendar { 112 | display: none; 113 | max-width: 270px; 114 | } 115 | 116 | .daterangepicker .drp-calendar.left { 117 | padding: 8px 0 8px 8px; 118 | } 119 | 120 | .daterangepicker .drp-calendar.right { 121 | padding: 8px; 122 | } 123 | 124 | .daterangepicker .drp-calendar.single .calendar-table { 125 | border: none; 126 | } 127 | 128 | .daterangepicker .calendar-table .next span, .daterangepicker .calendar-table .prev span { 129 | color: #fff; 130 | border: solid black; 131 | border-width: 0 2px 2px 0; 132 | border-radius: 0; 133 | display: inline-block; 134 | padding: 3px; 135 | } 136 | 137 | .daterangepicker .calendar-table .next span { 138 | transform: rotate(-45deg); 139 | -webkit-transform: rotate(-45deg); 140 | } 141 | 142 | .daterangepicker .calendar-table .prev span { 143 | transform: rotate(135deg); 144 | -webkit-transform: rotate(135deg); 145 | } 146 | 147 | .daterangepicker .calendar-table th, .daterangepicker .calendar-table td { 148 | white-space: nowrap; 149 | text-align: center; 150 | vertical-align: middle; 151 | min-width: 32px; 152 | width: 32px; 153 | height: 24px; 154 | line-height: 24px; 155 | font-size: 12px; 156 | border-radius: 4px; 157 | border: 1px solid transparent; 158 | white-space: nowrap; 159 | cursor: pointer; 160 | } 161 | 162 | .daterangepicker .calendar-table { 163 | border: 1px solid #fff; 164 | border-radius: 4px; 165 | background-color: #fff; 166 | } 167 | 168 | .daterangepicker .calendar-table table { 169 | width: 100%; 170 | margin: 0; 171 | border-spacing: 0; 172 | border-collapse: collapse; 173 | } 174 | 175 | .daterangepicker td.available:hover, .daterangepicker th.available:hover { 176 | background-color: #eee; 177 | border-color: transparent; 178 | color: inherit; 179 | } 180 | 181 | .daterangepicker td.week, .daterangepicker th.week { 182 | font-size: 80%; 183 | color: #ccc; 184 | } 185 | 186 | .daterangepicker td.off, .daterangepicker td.off.in-range, .daterangepicker td.off.start-date, .daterangepicker td.off.end-date { 187 | background-color: #fff; 188 | border-color: transparent; 189 | color: #999; 190 | } 191 | 192 | .daterangepicker td.in-range { 193 | background-color: #ebf4f8; 194 | border-color: transparent; 195 | color: #000; 196 | border-radius: 0; 197 | } 198 | 199 | .daterangepicker td.start-date { 200 | border-radius: 4px 0 0 4px; 201 | } 202 | 203 | .daterangepicker td.end-date { 204 | border-radius: 0 4px 4px 0; 205 | } 206 | 207 | .daterangepicker td.start-date.end-date { 208 | border-radius: 4px; 209 | } 210 | 211 | .daterangepicker td.active, .daterangepicker td.active:hover { 212 | background-color: #357ebd; 213 | border-color: transparent; 214 | color: #fff; 215 | } 216 | 217 | .daterangepicker th.month { 218 | width: auto; 219 | } 220 | 221 | .daterangepicker td.disabled, .daterangepicker option.disabled { 222 | color: #999; 223 | cursor: not-allowed; 224 | text-decoration: line-through; 225 | } 226 | 227 | .daterangepicker select.monthselect, .daterangepicker select.yearselect { 228 | font-size: 12px; 229 | padding: 1px; 230 | height: auto; 231 | margin: 0; 232 | cursor: default; 233 | } 234 | 235 | .daterangepicker select.monthselect { 236 | margin-right: 2%; 237 | width: 56%; 238 | } 239 | 240 | .daterangepicker select.yearselect { 241 | width: 40%; 242 | } 243 | 244 | .daterangepicker select.hourselect, .daterangepicker select.minuteselect, .daterangepicker select.secondselect, .daterangepicker select.ampmselect { 245 | width: 50px; 246 | margin: 0 auto; 247 | background: #eee; 248 | border: 1px solid #eee; 249 | padding: 2px; 250 | outline: 0; 251 | font-size: 12px; 252 | } 253 | 254 | .daterangepicker .calendar-time { 255 | text-align: center; 256 | margin: 4px auto 0 auto; 257 | line-height: 30px; 258 | position: relative; 259 | } 260 | 261 | .daterangepicker .calendar-time select.disabled { 262 | color: #ccc; 263 | cursor: not-allowed; 264 | } 265 | 266 | .daterangepicker .drp-buttons { 267 | clear: both; 268 | text-align: right; 269 | padding: 8px; 270 | border-top: 1px solid #ddd; 271 | display: none; 272 | line-height: 12px; 273 | vertical-align: middle; 274 | } 275 | 276 | .daterangepicker .drp-selected { 277 | display: inline-block; 278 | font-size: 12px; 279 | padding-right: 8px; 280 | } 281 | 282 | .daterangepicker .drp-buttons .btn { 283 | margin-left: 8px; 284 | font-size: 12px; 285 | font-weight: bold; 286 | padding: 4px 8px; 287 | } 288 | 289 | .daterangepicker.show-ranges.single.rtl .drp-calendar.left { 290 | border-right: 1px solid #ddd; 291 | } 292 | 293 | .daterangepicker.show-ranges.single.ltr .drp-calendar.left { 294 | border-left: 1px solid #ddd; 295 | } 296 | 297 | .daterangepicker.show-ranges.rtl .drp-calendar.right { 298 | border-right: 1px solid #ddd; 299 | } 300 | 301 | .daterangepicker.show-ranges.ltr .drp-calendar.left { 302 | border-left: 1px solid #ddd; 303 | } 304 | 305 | .daterangepicker .ranges { 306 | float: none; 307 | text-align: left; 308 | margin: 0; 309 | } 310 | 311 | .daterangepicker.show-calendar .ranges { 312 | margin-top: 8px; 313 | } 314 | 315 | .daterangepicker .ranges ul { 316 | list-style: none; 317 | margin: 0 auto; 318 | padding: 0; 319 | width: 100%; 320 | } 321 | 322 | .daterangepicker .ranges li { 323 | font-size: 12px; 324 | padding: 8px 12px; 325 | cursor: pointer; 326 | } 327 | 328 | .daterangepicker .ranges li:hover { 329 | background-color: #eee; 330 | } 331 | 332 | .daterangepicker .ranges li.active { 333 | background-color: #08c; 334 | color: #fff; 335 | } 336 | 337 | /* Larger Screen Styling */ 338 | @media (min-width: 564px) { 339 | .daterangepicker { 340 | width: auto; 341 | } 342 | 343 | .daterangepicker .ranges ul { 344 | width: 140px; 345 | } 346 | 347 | .daterangepicker.single .ranges ul { 348 | width: 100%; 349 | } 350 | 351 | .daterangepicker.single .drp-calendar.left { 352 | clear: none; 353 | } 354 | 355 | .daterangepicker.single .ranges, .daterangepicker.single .drp-calendar { 356 | float: left; 357 | } 358 | 359 | .daterangepicker { 360 | direction: ltr; 361 | text-align: left; 362 | } 363 | 364 | .daterangepicker .drp-calendar.left { 365 | clear: left; 366 | margin-right: 0; 367 | } 368 | 369 | .daterangepicker .drp-calendar.left .calendar-table { 370 | border-right: none; 371 | border-top-right-radius: 0; 372 | border-bottom-right-radius: 0; 373 | } 374 | 375 | .daterangepicker .drp-calendar.right { 376 | margin-left: 0; 377 | } 378 | 379 | .daterangepicker .drp-calendar.right .calendar-table { 380 | border-left: none; 381 | border-top-left-radius: 0; 382 | border-bottom-left-radius: 0; 383 | } 384 | 385 | .daterangepicker .drp-calendar.left .calendar-table { 386 | padding-right: 8px; 387 | } 388 | 389 | .daterangepicker .ranges, .daterangepicker .drp-calendar { 390 | float: left; 391 | } 392 | } 393 | 394 | @media (min-width: 730px) { 395 | .daterangepicker .ranges { 396 | width: auto; 397 | } 398 | 399 | .daterangepicker .ranges { 400 | float: left; 401 | } 402 | 403 | .daterangepicker.rtl .ranges { 404 | float: right; 405 | } 406 | 407 | .daterangepicker .drp-calendar.left { 408 | clear: none !important; 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /ledger/cmd/static/dropdown.css: -------------------------------------------------------------------------------- 1 | /*! padding.css */ 2 | 3 | @media screen and (min-width: 768px) { 4 | .dropdown-menu { 5 | height: auto; 6 | max-height: 400px; 7 | overflow-x: hidden; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ledger/cmd/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/ledger/cmd/static/favicon.ico -------------------------------------------------------------------------------- /ledger/cmd/stats.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "strings" 8 | "time" 9 | 10 | "github.com/hako/durafmt" 11 | "github.com/howeyc/ledger" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // statsCmd represents the stats command 16 | var statsCmd = &cobra.Command{ 17 | Use: "stats", 18 | Short: "A small report of transaction stats", 19 | Run: func(_ *cobra.Command, _ []string) { 20 | transactions, terr := cliTransactions() 21 | if terr != nil { 22 | log.Fatalln(terr) 23 | } 24 | printStats(transactions) 25 | }, 26 | } 27 | 28 | func printStats(generalLedger []*ledger.Transaction) { 29 | if len(generalLedger) < 1 { 30 | fmt.Println("Empty ledger.") 31 | return 32 | } 33 | 34 | startDate := generalLedger[0].Date 35 | endDate := generalLedger[len(generalLedger)-1].Date 36 | 37 | payees := make(map[string]struct{}) 38 | cipayees := make(map[string]struct{}) 39 | accounts := make(map[string]struct{}) 40 | 41 | var postings int64 42 | for _, trans := range generalLedger { 43 | payees[trans.Payee] = struct{}{} 44 | for _, account := range trans.AccountChanges { 45 | postings++ 46 | accounts[account.Name] = struct{}{} 47 | } 48 | } 49 | for p := range payees { 50 | cipayees[strings.ToLower(strings.TrimSpace(p))] = struct{}{} 51 | } 52 | 53 | days := math.Floor(endDate.Sub(startDate).Hours() / 24) 54 | 55 | fmt.Printf("%-25s : %s to %s (%s)\n", "Time period", startDate.Format(time.DateOnly), endDate.Format(time.DateOnly), durafmt.Parse(endDate.Sub(startDate)).String()) 56 | fmt.Printf("%-25s : %d\n", "Unique payees", len(cipayees)) 57 | fmt.Printf("%-25s : %d\n", "Unique accounts", len(accounts)) 58 | fmt.Printf("%-25s : %d (%.1f per day)\n", "Number of transactions", len(generalLedger), float64(len(generalLedger))/days) 59 | fmt.Printf("%-25s : %d (%.1f per day)\n", "Number of postings", postings, float64(postings)/days) 60 | fmt.Printf("%-25s : %s\n", "Time since last post", durafmt.ParseShort(time.Since(endDate)).String()) 61 | } 62 | 63 | func init() { 64 | rootCmd.AddCommand(statsCmd) 65 | } 66 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.account.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Ledger - Account ({{index .AccountNames 0}}) 14 | 15 | {{template "common-css"}} 16 | 17 | 18 | 19 | 20 | 21 | {{template "nav" .}} 22 | 23 |
24 |
25 |
26 |
27 |

Account {{index .AccountNames 0}}

28 |
29 |
30 |
31 |
32 |
33 |
34 | 35 | {{template "payee-transaction-table" .}} 36 | 37 |
38 |
39 |
40 |
41 | 42 | 43 | {{template "common-scripts"}} 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.accounts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Ledger - Account List 11 | 12 | {{template "common-css"}} 13 | 14 | 15 | 16 | 17 | 18 | {{template "nav" .}} 19 | 20 |
21 |
22 |
23 |
24 |

Account List

25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {{range .Accounts}} 43 | 44 | 45 | 46 | 47 | 48 | {{end}} 49 | 50 |
AccountBalanceBalance
{{abbrev .Name}}{{.Name}}{{.Balance.StringFixedBank}}
51 |
52 |
53 |
54 |
55 | 56 | {{template "common-scripts"}} 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.addtransaction.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Ledger - Add Transaction 12 | 13 | {{template "common-css"}} 14 | 15 | 16 | 17 | 18 | 19 | {{template "nav" .}} 20 | 21 |
22 |
23 |
24 |
25 |

Add Transaction

26 |
27 |
28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 | 36 |
37 |
38 | 39 |
40 |
41 |
42 |
43 | 49 |
50 |
51 | 52 |
53 |
54 |
55 |
56 | 62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 | 75 |
76 |
77 | 78 |
79 |
80 |
81 |
82 | 83 |
84 |
85 | 86 |
87 |
88 |
89 | 90 |
91 |
92 | 93 | 94 | 95 | {{template "common-scripts"}} 96 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.barlinechart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Ledger - Report - {{.ReportName}} 11 | 12 | {{template "common-css"}} 13 | 14 | 15 | 16 | 17 | {{template "nav" .}} 18 | 19 |
20 |
21 |
22 |
23 |

{{.ReportName}} : {{.RangeStart.Format "2006-01-02"}} - {{.RangeEnd.Format "2006-01-02"}}

24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 | 44 | {{template "payee-transaction-table" .}} 45 | 46 |
47 |
48 | 49 |
50 |
51 | 52 | 53 | {{template "common-scripts"}} 54 | 146 | 147 | 148 | 149 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.common.html: -------------------------------------------------------------------------------- 1 | {{define "common-css"}} 2 | 3 | 4 | 5 | 6 | {{end}} 7 | {{define "common-scripts"}} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 115 | {{end}} 116 | {{define "payee-transaction-table"}} 117 |
118 | Loading... 119 | 120 |
121 | 122 | 163 | {{end}} 164 | {{define "nav"}} 165 | 166 | 220 | {{end}} 221 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.leaderboardchart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Ledger - Report - {{.ReportName}} 11 | 12 | {{template "common-css"}} 13 | 14 | 15 | 16 | 17 | 18 | {{template "nav" .}} 19 | 20 |
21 |
22 |
23 |
24 |

{{.ReportName}} : {{.RangeStart.Format "2006-01-02"}} - {{.RangeEnd.Format "2006-01-02"}}

25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {{range $idx, $acc := .ChartAccounts}} 40 | 41 | 42 | 49 | 50 | {{end}} 51 | 52 |
NameAmount
{{lastaccount $acc.Name}} 43 |
44 |
45 | {{$acc.Balance.StringRound}} ({{$acc.Percentage}}%) 46 |
47 |
48 |
53 |
54 |
55 | 56 |
57 |
58 | {{template "payee-transaction-table" .}} 59 |
60 |
61 | 62 |
63 |
64 | 65 | 66 | {{template "common-scripts"}} 67 | 68 | 69 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.ledger.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Ledger - General Ledger 11 | 12 | {{template "common-css"}} 13 | 14 | 15 | 16 | 17 | 18 | {{template "nav" .}} 19 | 20 |
21 |
22 |
23 |
24 |

Ledger

25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | 33 |
34 | Loading... 35 | 36 |
37 | 38 | 68 |
69 |
70 |
71 |
72 | 73 | {{template "common-scripts"}} 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.piechart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Ledger - Report - {{.ReportName}} 11 | 12 | {{template "common-css"}} 13 | 14 | 15 | 16 | 17 | 18 | {{template "nav" .}} 19 | 20 |
21 |
22 |
23 |
24 |

{{.ReportName}} : {{.RangeStart.Format "2006-01-02"}} - {{.RangeEnd.Format "2006-01-02"}}

25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | 42 |
43 |
44 | {{template "payee-transaction-table" .}} 45 |
46 |
47 | 48 |
49 |
50 | 51 | 52 | {{template "common-scripts"}} 53 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.portfolio.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Ledger - Portfolio 11 | 12 | {{template "common-css"}} 13 | 14 | 15 | 16 | 17 | 18 | {{template "nav" .}} 19 | 20 |
21 |
22 |
23 |
24 |

Portfolio {{.PortfolioName}} - Overall

25 |
26 | 27 |
28 |
29 |
30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {{if $.ShowWeight}} 40 | 41 | {{end}} 42 | {{if $.ShowDividends}} 43 | 44 | 45 | {{end}} 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | {{range .Stocks}} 55 | 56 | 57 | 58 | 59 | {{if $.ShowWeight}} 60 | 61 | {{end}} 62 | {{if $.ShowDividends}} 63 | 64 | 65 | {{end}} 66 | 67 | 68 | 69 | 70 | 71 | 72 | {{end}} 73 | 74 |
NameCostMarket ValueWeightAnnual DividendsAnnual YieldPricePct ChgGain / LossOvr Pct ChgGain / Loss
{{.Name}}{{printf "%.2f" .Cost}}{{printf "%.2f" .MarketValue}}{{printf "%.2f" .Weight}}{{printf "%.2f" .AnnualDividends}}{{printf "%.2f" .AnnualYield}}{{printf "%.2f" .Price}}

{{printf "%.2f" .PriceChangePctDay}}

{{printf "%.2f" .GainLossDay}}

{{printf "%.2f" .PriceChangePctOverall}}

{{printf "%.2f" .GainLossOverall}}

75 |
76 |
77 |
78 |
79 | 80 | 81 | {{template "common-scripts"}} 82 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /ledger/cmd/templates/template.quickview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Ledger - Account List 11 | 12 | {{template "common-css"}} 13 | 14 | 15 | 16 | 17 | 18 | {{template "nav" .}} 19 | 20 |
21 |
22 |
23 |
24 |

Account List

25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {{range .Accounts}} 42 | 43 | 49 | 55 | 56 | 57 | {{end}} 58 | 59 |
AccountBalance
44 | {{qvshortname .Name}} 45 | {{if not $.ReadOnly}} 46 | 47 | {{end}} 48 | 50 | {{.Name}} 51 | {{if not $.ReadOnly}} 52 | 53 | {{end}} 54 | {{.Balance.StringFixedBank}}
60 |
61 |
62 |
63 |
64 | 65 | 66 | {{template "common-scripts"}} 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /ledger/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | version = "dev" 12 | ) 13 | 14 | // versionCmd represents the version command 15 | var versionCmd = &cobra.Command{ 16 | Use: "version", 17 | Short: "Version of ledger", 18 | Run: func(_ *cobra.Command, _ []string) { 19 | fmt.Printf("ledger %s\n", version) 20 | if bi, ok := debug.ReadBuildInfo(); ok { 21 | fmt.Print(bi) 22 | } 23 | }, 24 | } 25 | 26 | func init() { 27 | rootCmd.AddCommand(versionCmd) 28 | } 29 | -------------------------------------------------------------------------------- /ledger/cmd/web-portfolio-sample.toml: -------------------------------------------------------------------------------- 1 | # Used for "Stock" security_type -- see https://iexcloud.io/docs/api/ 2 | iex_token = "pk_tokenstring" 3 | 4 | # Used for "Fund" security_type -- see https://www.alphavantage.co/documentation 5 | av_token = "apikey" 6 | 7 | [[porfolio]] 8 | name = "Stocks" 9 | show_dividends = true 10 | 11 | [[portfolio.stock]] 12 | name = "Vanguard" 13 | security_type = "Fund" 14 | section = "Fund" 15 | ticker = "VASGX" 16 | account = "Assets:Investments:TD Ameritrade:Invested:Fund:VASGX" 17 | shares = 3000 18 | 19 | [[portfolio.stock]] 20 | name = "S&P 500" 21 | security_type = "Stock" 22 | section = "ETF" 23 | ticker = "IVV" 24 | account = "Assets:Investments:TD Ameritrade:Invested:ETF:IVV" 25 | shares = 3000 26 | 27 | [[portfolio.stock]] 28 | name = "S&P 500 High Div" 29 | security_type = "Stock" 30 | section = "ETF" 31 | ticker = "SPHD" 32 | account = "Assets:Investments:TD Ameritrade:Invested:ETF:SPHD" 33 | shares = 3000 34 | 35 | [[portfolio]] 36 | name = "Crypto Holdings" 37 | 38 | [[portfolio.stock]] 39 | name = "Litecoin" 40 | security_type = "Crypto" 41 | section = "LTC" 42 | ticker = "LTC-USD" 43 | account = "Assets:Investments:Crypto:LTC" 44 | shares = 10 45 | -------------------------------------------------------------------------------- /ledger/cmd/web-quickview-sample.toml: -------------------------------------------------------------------------------- 1 | [[account]] 2 | name = "Assets:Current Assets:TD Bank:Checking" 3 | short_name = "TD Checking" 4 | 5 | [[account]] 6 | name = "Assets:Current Assets:Wallet" 7 | short_name = "Wallet" 8 | 9 | [[account]] 10 | name = "Liabilities:Credit Card:TD Visa" 11 | short_name = "TD Visa" 12 | -------------------------------------------------------------------------------- /ledger/cmd/web-reports-sample.toml: -------------------------------------------------------------------------------- 1 | [[report]] 2 | name = "PY Job Expenses" 3 | chart = "pie" 4 | date_range = "Previous Year" 5 | accounts = [ "Expenses:Job Expenses:*" ] 6 | 7 | [[report]] 8 | name = "PQ Expenses" 9 | chart = "pie" 10 | date_range = "Previous Quarter" 11 | accounts = [ "Expenses:*" ] 12 | exclude_account_summary = [ "Job Expenses" ] 13 | 14 | [[report]] 15 | name = "PY Expenses" 16 | chart = "pie" 17 | date_range = "Previous Year" 18 | accounts = [ "Expenses:*" ] 19 | exclude_account_summary = [ "Job Expenses", "Taxes" ] 20 | 21 | [[report]] 22 | name = "YTD Expenses" 23 | chart = "pie" 24 | date_range = "YTD" 25 | accounts = [ "Expenses:*" ] 26 | 27 | [[report]] 28 | name = "YTD My Monthly Savings" 29 | chart = "bar" 30 | date_range = "YTD" 31 | date_freq = "Monthly" 32 | accounts = [ "Income", "Expenses" ] 33 | exclude_account_trans = [ "Income:Wife" ] 34 | 35 | [[report.calculated_account]] 36 | name = "Savings" 37 | 38 | [[report.calculated_account.account_operation]] 39 | name = "Income" 40 | operation = "+" 41 | 42 | [[report.calculated_account.account_operation]] 43 | name = "Expenses" 44 | operation = "-" 45 | 46 | [[report]] 47 | name = "AT Net Worth" 48 | chart = "line" 49 | date_range = "All Time" 50 | date_freq = "Quarterly" 51 | accounts = [ "Assets", "Liabilities" ] 52 | 53 | [[report]] 54 | name = "AT Vehicle Depreciation" 55 | chart = "line" 56 | date_range = "All Time" 57 | date_freq = "Quarterly" 58 | accounts = [ "Assets:Fixed Assets", "Expenses:Depreciation" ,"Liabilities:Loans:Auto" ] 59 | 60 | [[report]] 61 | name = "AT Vehicle Expenses" 62 | chart = "stackedbar" 63 | date_range = "All Time" 64 | date_freq = "Yearly" 65 | accounts = [ "Expenses:Depreciation", "Expenses:Auto:Gas", "Expenses:Auto:Insurance", "Expenses:Auto:Maintenance" ] 66 | 67 | [[report]] 68 | name = "AT Yearly Income" 69 | chart = "bar" 70 | date_range = "All Time" 71 | date_freq = "Yearly" 72 | accounts = [ "Income" ] 73 | 74 | -------------------------------------------------------------------------------- /ledger/cmd/web.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "slices" 9 | "time" 10 | 11 | "github.com/howeyc/ledger/ledger/cmd/internal/httpcompress" 12 | 13 | "github.com/howeyc/ledger" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var reportConfigFileName string 18 | var stockConfigFileName string 19 | var quickviewConfigFileName string 20 | 21 | var serverPort int 22 | var localhost bool 23 | var webReadOnly bool 24 | 25 | //go:embed static/* 26 | var contentStatic embed.FS 27 | 28 | //go:embed templates/* 29 | var contentTemplates embed.FS 30 | 31 | func getTransactions() ([]*ledger.Transaction, error) { 32 | trans, terr := ledger.ParseLedgerFile(ledgerFilePath) 33 | if terr != nil { 34 | return nil, fmt.Errorf("%s", terr.Error()) 35 | } 36 | slices.SortStableFunc(trans, func(a, b *ledger.Transaction) int { 37 | return a.Date.Compare(b.Date) 38 | }) 39 | return trans, nil 40 | } 41 | 42 | // webCmd represents the web command 43 | var webCmd = &cobra.Command{ 44 | Use: "web", 45 | Short: "Web service", 46 | Run: func(_ *cobra.Command, _ []string) { 47 | configLoaders(time.Minute * 5) 48 | 49 | // initialize cache 50 | if _, err := getTransactions(); err != nil { 51 | log.Fatalln(err) 52 | } 53 | 54 | m := http.NewServeMux() 55 | 56 | fileServer := http.FileServer(http.FS(contentStatic)) 57 | m.HandleFunc("GET /static/{filepath...}", func(w http.ResponseWriter, req *http.Request) { 58 | w.Header().Set("Vary", "Accept-Encoding") 59 | w.Header().Set("Cache-Control", "public, max-age=7776000") 60 | req.URL.Path = "/static/" + req.PathValue("filepath") 61 | fileServer.ServeHTTP(w, req) 62 | }) 63 | 64 | if !webReadOnly { 65 | m.HandleFunc("GET /addtrans", httpcompress.Middleware(addTransactionHandler, false)) 66 | m.HandleFunc("GET /addtrans/{accountName}", httpcompress.Middleware(addQuickTransactionHandler, false)) 67 | m.HandleFunc("POST /addtrans", httpcompress.Middleware(addTransactionPostHandler, false)) 68 | } 69 | 70 | m.HandleFunc("GET /ledger", httpcompress.Middleware(ledgerHandler, false)) 71 | m.HandleFunc("GET /accounts", httpcompress.Middleware(accountsHandler, false)) 72 | m.HandleFunc("GET /portfolio/{portfolioName}", httpcompress.Middleware(portfolioHandler, false)) 73 | m.HandleFunc("GET /account/{accountName}", httpcompress.Middleware(accountHandler, false)) 74 | m.HandleFunc("GET /report/{reportName}", httpcompress.Middleware(reportHandler, false)) 75 | m.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, req *http.Request) { 76 | req.URL.Path = "/static/favicon.ico" 77 | fileServer.ServeHTTP(w, req) 78 | }) 79 | m.HandleFunc("/", httpcompress.Middleware(quickviewHandler, false)) 80 | 81 | log.Println("Listening on port", serverPort) 82 | var listenAddress string 83 | if localhost { 84 | listenAddress = fmt.Sprintf("127.0.0.1:%d", serverPort) 85 | } else { 86 | listenAddress = fmt.Sprintf(":%d", serverPort) 87 | } 88 | log.Fatalln(http.ListenAndServe(listenAddress, m)) 89 | }, 90 | } 91 | 92 | func init() { 93 | rootCmd.AddCommand(webCmd) 94 | 95 | webCmd.Flags().StringVarP(&reportConfigFileName, "reports", "r", "", "Report config file name.") 96 | webCmd.Flags().StringVarP(&stockConfigFileName, "portfolio", "s", "", "Stock config file name.") 97 | webCmd.Flags().StringVarP(&quickviewConfigFileName, "quickview", "q", "", "Quickview config file name.") 98 | webCmd.Flags().IntVar(&serverPort, "port", 8056, "Port to listen on.") 99 | webCmd.Flags().BoolVar(&localhost, "localhost", false, "Listen on localhost only.") 100 | webCmd.Flags().BoolVar(&webReadOnly, "read-only", false, "Disable adding transactions through web.") 101 | } 102 | -------------------------------------------------------------------------------- /ledger/cmd/webConfig.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | 8 | "github.com/howeyc/ledger" 9 | "github.com/pelletier/go-toml" 10 | ) 11 | 12 | type accountOp struct { 13 | Name string `toml:"name"` 14 | Operation string `toml:"operation"` // +, - 15 | MultiplicationFactor float64 `toml:"factor"` 16 | SubAccount string `toml:"other_account"` // *, / 17 | } 18 | 19 | type calculatedAccount struct { 20 | Name string `toml:"name"` 21 | UseAbs bool `toml:"use_abs"` 22 | AccountOperations []accountOp `toml:"account_operation"` 23 | } 24 | 25 | type reportConfig struct { 26 | Name string 27 | Chart string 28 | RangeBalanceType ledger.RangeType `toml:"range_balance_type"` 29 | RangeBalanceSkipZero bool `toml:"range_balance_skip_zero"` 30 | DateRange string `toml:"date_range"` 31 | DateFreq string `toml:"date_freq"` 32 | Accounts []string 33 | ExcludeAccountTrans []string `toml:"exclude_account_trans"` 34 | ExcludeAccountsSummary []string `toml:"exclude_account_summary"` 35 | CalculatedAccounts []calculatedAccount `toml:"calculated_account"` 36 | } 37 | 38 | type reportConfigStruct struct { 39 | Reports []reportConfig `toml:"report"` 40 | } 41 | 42 | var reportConfigData reportConfigStruct 43 | 44 | type quickviewAccountConfig struct { 45 | Name string 46 | ShortName string `toml:"short_name"` 47 | } 48 | 49 | type quickviewConfigStruct struct { 50 | Accounts []quickviewAccountConfig `toml:"account"` 51 | } 52 | 53 | var quickviewConfigData quickviewConfigStruct 54 | 55 | type stockConfig struct { 56 | Name string 57 | SecurityType string `toml:"security_type"` 58 | Section string 59 | Ticker string 60 | Account string 61 | Shares float64 62 | } 63 | 64 | type stockInfo struct { 65 | Name string 66 | Section string 67 | Type string 68 | Ticker string 69 | Account string 70 | Shares float64 71 | 72 | Price float64 73 | PriceChangeDay float64 74 | PriceChangePctDay float64 75 | PriceChangeOverall float64 76 | PriceChangePctOverall float64 77 | 78 | Cost float64 79 | MarketValue float64 80 | GainLossDay float64 81 | GainLossOverall float64 82 | 83 | Weight float64 84 | 85 | AnnualDividends float64 86 | AnnualYield float64 87 | } 88 | 89 | type portfolioStruct struct { 90 | Name string 91 | 92 | ShowDividends bool `toml:"show_dividends"` 93 | ShowWeight bool `toml:"show_weight"` 94 | 95 | Stocks []stockConfig `toml:"stock"` 96 | } 97 | 98 | type portfolioConfigStruct struct { 99 | Portfolios []portfolioStruct `toml:"portfolio"` 100 | AVToken string `toml:"av_token"` 101 | } 102 | 103 | var portfolioConfigData portfolioConfigStruct 104 | 105 | type pageData struct { 106 | Reports []reportConfig 107 | Transactions []*ledger.Transaction 108 | Accounts []*ledger.Account 109 | Stocks []stockInfo 110 | Portfolios []portfolioStruct 111 | AccountNames []string 112 | ReadOnly bool 113 | } 114 | 115 | func (p *pageData) Init() { 116 | p.ReadOnly = webReadOnly 117 | p.Reports = reportConfigData.Reports 118 | p.Portfolios = portfolioConfigData.Portfolios 119 | } 120 | 121 | func configLoaders(dur time.Duration) { 122 | if len(reportConfigFileName) > 0 { 123 | go func() { 124 | for { 125 | var rLoadData reportConfigStruct 126 | ifile, ierr := os.Open(reportConfigFileName) 127 | if ierr != nil { 128 | log.Println(ierr) 129 | } 130 | tdec := toml.NewDecoder(ifile) 131 | err := tdec.Decode(&rLoadData) 132 | if err != nil { 133 | log.Println(err) 134 | } 135 | ifile.Close() 136 | reportConfigData = rLoadData 137 | time.Sleep(dur) 138 | } 139 | }() 140 | } 141 | 142 | if len(quickviewConfigFileName) > 0 { 143 | go func() { 144 | for { 145 | var sLoadData quickviewConfigStruct 146 | ifile, ierr := os.Open(quickviewConfigFileName) 147 | if ierr != nil { 148 | log.Println(ierr) 149 | } 150 | tdec := toml.NewDecoder(ifile) 151 | err := tdec.Decode(&sLoadData) 152 | if err != nil { 153 | log.Println(err) 154 | } 155 | ifile.Close() 156 | quickviewConfigData = sLoadData 157 | time.Sleep(dur) 158 | } 159 | }() 160 | } 161 | 162 | if len(stockConfigFileName) > 0 { 163 | go func() { 164 | for { 165 | var sLoadData portfolioConfigStruct 166 | ifile, ierr := os.Open(stockConfigFileName) 167 | if ierr != nil { 168 | log.Println(ierr) 169 | } 170 | tdec := toml.NewDecoder(ifile) 171 | err := tdec.Decode(&sLoadData) 172 | if err != nil { 173 | log.Println(err) 174 | } 175 | ifile.Close() 176 | portfolioConfigData = sLoadData 177 | time.Sleep(dur) 178 | } 179 | }() 180 | } 181 | 182 | } 183 | -------------------------------------------------------------------------------- /ledger/cmd/webHandlerAccounts.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/howeyc/ledger" 12 | ) 13 | 14 | func quickviewHandler(w http.ResponseWriter, r *http.Request) { 15 | if len(quickviewConfigData.Accounts) < 1 { 16 | http.Redirect(w, r, "/accounts", http.StatusFound) 17 | return 18 | } 19 | 20 | t, err := loadTemplates("templates/template.quickview.html") 21 | if err != nil { 22 | http.Error(w, err.Error(), 500) 23 | return 24 | } 25 | 26 | trans, terr := getTransactions() 27 | if terr != nil { 28 | http.Error(w, terr.Error(), 500) 29 | return 30 | } 31 | 32 | var pData pageData 33 | pData.Init() 34 | pData.Transactions = trans 35 | 36 | includeNames := make(map[string]bool) 37 | for _, qvc := range quickviewConfigData.Accounts { 38 | includeNames[qvc.Name] = true 39 | } 40 | 41 | balances := ledger.GetBalances(trans, []string{}) 42 | for _, bal := range balances { 43 | if includeNames[bal.Name] { 44 | pData.Accounts = append(pData.Accounts, bal) 45 | } 46 | } 47 | 48 | err = t.Execute(w, pData) 49 | if err != nil { 50 | http.Error(w, err.Error(), 500) 51 | } 52 | } 53 | 54 | func addTransactionPostHandler(w http.ResponseWriter, r *http.Request) { 55 | strDate := r.FormValue("transactionDate") 56 | strPayee := r.FormValue("transactionPayee") 57 | 58 | var accountLines []string 59 | for i := 1; i < 20; i++ { 60 | strAcc := r.FormValue(fmt.Sprintf("transactionAccount%d", i)) 61 | strAmt := r.FormValue(fmt.Sprintf("transactionAmount%d", i)) 62 | accountLines = append(accountLines, strings.Trim(fmt.Sprintf("%s %s", strAcc, strAmt), " \t")) 63 | } 64 | 65 | date, _ := time.Parse(time.DateOnly, strDate) 66 | 67 | var tbuf bytes.Buffer 68 | fmt.Fprintln(&tbuf, date.Format("2006/01/02"), strPayee) 69 | for _, accLine := range accountLines { 70 | if len(accLine) > 0 { 71 | fmt.Fprintf(&tbuf, " %s", accLine) 72 | fmt.Fprintln(&tbuf, "") 73 | } 74 | } 75 | fmt.Fprintln(&tbuf, "") 76 | 77 | /* Check valid transaction is created */ 78 | trans, perr := ledger.ParseLedger(&tbuf) 79 | if perr != nil { 80 | http.Error(w, perr.Error(), 500) 81 | return 82 | } 83 | 84 | f, err := os.OpenFile(ledgerFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 85 | if err != nil { 86 | http.Error(w, err.Error(), 500) 87 | return 88 | } 89 | for _, t := range trans { 90 | WriteTransaction(f, t, 80) 91 | } 92 | f.Close() 93 | 94 | if _, err := getTransactions(); err != nil { 95 | http.Error(w, err.Error(), 500) 96 | return 97 | } 98 | fmt.Fprintf(w, "Transaction added!") 99 | } 100 | 101 | func addQuickTransactionHandler(w http.ResponseWriter, r *http.Request) { 102 | accountName := r.PathValue("accountName") 103 | 104 | t, err := loadTemplates("templates/template.addtransaction.html") 105 | if err != nil { 106 | http.Error(w, err.Error(), 500) 107 | return 108 | } 109 | 110 | trans, terr := getTransactions() 111 | if terr != nil { 112 | http.Error(w, terr.Error(), 500) 113 | return 114 | } 115 | 116 | // Recent accounts 117 | monthsago := time.Now().AddDate(0, -3, 0) 118 | var atrans []*ledger.Transaction 119 | for _, tran := range trans { 120 | includeTrans := false 121 | if tran.Date.After(monthsago) { 122 | includeTrans = true 123 | // Filter by supplied account 124 | if accountName != "" { 125 | includeTrans = false 126 | for _, acc := range tran.AccountChanges { 127 | if acc.Name == accountName { 128 | includeTrans = true 129 | } 130 | } 131 | } 132 | } 133 | if includeTrans { 134 | atrans = append(atrans, tran) 135 | } 136 | } 137 | 138 | // Child non-zero balance accounts 139 | balances := ledger.GetBalances(atrans, []string{}) 140 | var abals []*ledger.Account 141 | for _, bal := range balances { 142 | accDepth := len(strings.Split(bal.Name, ":")) 143 | if !bal.Balance.IsZero() && accDepth > 2 { 144 | abals = append(abals, bal) 145 | } 146 | } 147 | 148 | var pData pageData 149 | pData.Init() 150 | pData.Accounts = abals 151 | pData.Transactions = atrans 152 | pData.AccountNames = []string{accountName} 153 | 154 | err = t.Execute(w, pData) 155 | if err != nil { 156 | http.Error(w, err.Error(), 500) 157 | } 158 | } 159 | 160 | func addTransactionHandler(w http.ResponseWriter, _ *http.Request) { 161 | t, err := loadTemplates("templates/template.addtransaction.html") 162 | if err != nil { 163 | http.Error(w, err.Error(), 500) 164 | return 165 | } 166 | 167 | trans, terr := getTransactions() 168 | if terr != nil { 169 | http.Error(w, terr.Error(), 500) 170 | return 171 | } 172 | balances := ledger.GetBalances(trans, []string{}) 173 | 174 | var pData pageData 175 | pData.Init() 176 | pData.Accounts = balances 177 | pData.Transactions = trans 178 | 179 | err = t.Execute(w, pData) 180 | if err != nil { 181 | http.Error(w, err.Error(), 500) 182 | } 183 | } 184 | 185 | func accountsHandler(w http.ResponseWriter, _ *http.Request) { 186 | t, err := loadTemplates("templates/template.accounts.html") 187 | if err != nil { 188 | http.Error(w, err.Error(), 500) 189 | return 190 | } 191 | 192 | trans, terr := getTransactions() 193 | if terr != nil { 194 | http.Error(w, terr.Error(), 500) 195 | return 196 | } 197 | 198 | balances := ledger.GetBalances(trans, []string{}) 199 | 200 | var pData pageData 201 | pData.Init() 202 | pData.Accounts = balances 203 | pData.Transactions = trans 204 | 205 | err = t.Execute(w, pData) 206 | if err != nil { 207 | http.Error(w, err.Error(), 500) 208 | } 209 | } 210 | 211 | func accountHandler(w http.ResponseWriter, r *http.Request) { 212 | accountName := r.PathValue("accountName") 213 | 214 | t, err := loadTemplates("templates/template.account.html") 215 | if err != nil { 216 | http.Error(w, err.Error(), 500) 217 | return 218 | } 219 | 220 | trans, terr := getTransactions() 221 | if terr != nil { 222 | http.Error(w, terr.Error(), 500) 223 | return 224 | } 225 | 226 | var pageTrans []*ledger.Transaction 227 | for _, tran := range trans { 228 | for _, accChange := range tran.AccountChanges { 229 | if strings.Contains(accChange.Name, accountName) { 230 | pageTrans = append(pageTrans, &ledger.Transaction{ 231 | Payee: tran.Payee, 232 | Date: tran.Date, 233 | AccountChanges: []ledger.Account{accChange}, 234 | }) 235 | } 236 | } 237 | } 238 | 239 | var pData pageData 240 | pData.Init() 241 | pData.Transactions = pageTrans 242 | pData.AccountNames = []string{accountName} 243 | 244 | err = t.Execute(w, pData) 245 | if err != nil { 246 | http.Error(w, err.Error(), 500) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /ledger/cmd/webHandlerLedger.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func ledgerHandler(w http.ResponseWriter, _ *http.Request) { 8 | t, err := loadTemplates("templates/template.ledger.html") 9 | if err != nil { 10 | http.Error(w, err.Error(), 500) 11 | return 12 | } 13 | 14 | trans, terr := getTransactions() 15 | if terr != nil { 16 | http.Error(w, terr.Error(), 500) 17 | return 18 | } 19 | 20 | var pData pageData 21 | pData.Init() 22 | pData.Transactions = trans 23 | 24 | err = t.Execute(w, pData) 25 | if err != nil { 26 | http.Error(w, err.Error(), 500) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ledger/cmd/webHandlerPortfolio.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "cmp" 5 | "net/http" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/howeyc/ledger" 10 | ) 11 | 12 | func portfolioHandler(w http.ResponseWriter, r *http.Request) { 13 | portfolioName := r.PathValue("portfolioName") 14 | 15 | var portfolio portfolioStruct 16 | for _, port := range portfolioConfigData.Portfolios { 17 | if port.Name == portfolioName { 18 | portfolio = port 19 | } 20 | } 21 | 22 | t, err := loadTemplates("templates/template.portfolio.html") 23 | if err != nil { 24 | http.Error(w, err.Error(), 500) 25 | return 26 | } 27 | 28 | trans, terr := getTransactions() 29 | if terr != nil { 30 | http.Error(w, terr.Error(), 500) 31 | return 32 | } 33 | balances := ledger.GetBalances(trans, []string{}) 34 | 35 | type portPageData struct { 36 | pageData 37 | PortfolioName string 38 | ShowDividends bool 39 | ShowWeight bool 40 | } 41 | 42 | var pData portPageData 43 | pData.Init() 44 | pData.Transactions = trans 45 | pData.PortfolioName = portfolioName 46 | pData.ShowDividends = portfolio.ShowDividends 47 | pData.ShowWeight = portfolio.ShowWeight 48 | 49 | sectionTotals := make(map[string]stockInfo) 50 | siChan := make(chan stockInfo) 51 | 52 | for _, stock := range portfolio.Stocks { 53 | go func(name, account, symbol, securityType, section string, shares float64) { 54 | si := stockInfo{Name: name, 55 | Section: section, 56 | Ticker: symbol, 57 | Shares: shares} 58 | for _, bal := range balances { 59 | if account == bal.Name { 60 | si.Cost, _ = bal.Balance.Float64() 61 | } 62 | } 63 | 64 | cprice := si.Cost / si.Shares 65 | var sprice, sclose float64 66 | switch securityType { 67 | case "Stock", "Fund": 68 | quote, qerr := fundQuote(symbol) 69 | if qerr == nil { 70 | sprice = quote.Last 71 | sclose = quote.PreviousClose 72 | } 73 | if portfolio.ShowDividends { 74 | div := fundAnnualDividends(symbol) 75 | si.AnnualDividends = div * shares 76 | } 77 | case "Crypto": 78 | quote, qerr := cryptoQuote(symbol) 79 | if qerr == nil { 80 | sprice = quote.Last 81 | sclose = quote.PreviousClose 82 | } 83 | case "Cash": 84 | sprice = 1 85 | sclose = 1 86 | si.Shares = si.Cost 87 | default: 88 | sprice = cprice 89 | sclose = cprice 90 | } 91 | 92 | if sprice == 0 { 93 | sprice = sclose 94 | } 95 | 96 | si.Price = sprice 97 | si.MarketValue = si.Shares * si.Price 98 | si.GainLossOverall = si.MarketValue - si.Cost 99 | si.PriceChangeDay = sprice - sclose 100 | si.PriceChangePctDay = (si.PriceChangeDay / sclose) * 100.0 101 | si.PriceChangeOverall = sprice - cprice 102 | si.PriceChangePctOverall = (si.PriceChangeOverall / cprice) * 100.0 103 | si.GainLossDay = si.Shares * si.PriceChangeDay 104 | si.AnnualYield = (si.AnnualDividends / si.MarketValue) * 100 105 | siChan <- si 106 | }(stock.Name, stock.Account, stock.Ticker, stock.SecurityType, stock.Section, stock.Shares) 107 | } 108 | for range portfolio.Stocks { 109 | pData.Stocks = append(pData.Stocks, <-siChan) 110 | } 111 | 112 | stotal := stockInfo{Name: "Total", Section: "zzzTotal", Type: "Total"} 113 | for _, si := range pData.Stocks { 114 | sectionInfo := sectionTotals[si.Section] 115 | sectionInfo.Name = si.Section 116 | sectionInfo.Section = si.Section 117 | sectionInfo.Type = "Section Total" 118 | sectionInfo.Ticker = "zzz" 119 | sectionInfo.Cost += si.Cost 120 | sectionInfo.MarketValue += si.MarketValue 121 | sectionInfo.GainLossOverall += si.GainLossOverall 122 | sectionInfo.GainLossDay += si.GainLossDay 123 | sectionInfo.AnnualDividends += si.AnnualDividends 124 | sectionInfo.AnnualYield = (sectionInfo.AnnualDividends / sectionInfo.MarketValue) * 100 125 | sectionTotals[si.Section] = sectionInfo 126 | 127 | stotal.Cost += si.Cost 128 | stotal.MarketValue += si.MarketValue 129 | stotal.GainLossOverall += si.GainLossOverall 130 | stotal.GainLossDay += si.GainLossDay 131 | stotal.AnnualDividends += si.AnnualDividends 132 | } 133 | stotal.PriceChangePctDay = (stotal.GainLossDay / stotal.Cost) * 100.0 134 | stotal.PriceChangePctOverall = (stotal.GainLossOverall / stotal.Cost) * 100.0 135 | stotal.AnnualYield = (stotal.AnnualDividends / stotal.MarketValue) * 100 136 | pData.Stocks = append(pData.Stocks, stotal) 137 | 138 | for _, sectionInfo := range sectionTotals { 139 | sectionInfo.PriceChangePctDay = (sectionInfo.GainLossDay / sectionInfo.Cost) * 100.0 140 | sectionInfo.PriceChangePctOverall = (sectionInfo.GainLossOverall / sectionInfo.Cost) * 100.0 141 | 142 | for i, si := range pData.Stocks { 143 | if si.Section == sectionInfo.Name { 144 | pData.Stocks[i].Weight = (si.MarketValue / sectionInfo.MarketValue) * 100 145 | } 146 | } 147 | sectionInfo.Weight = (sectionInfo.MarketValue / stotal.MarketValue) * 100 148 | 149 | pData.Stocks = append(pData.Stocks, sectionInfo) 150 | } 151 | 152 | slices.SortFunc(pData.Stocks, func(a, b stockInfo) int { 153 | return cmp.Or( 154 | strings.Compare(a.Section, b.Section), 155 | strings.Compare(a.Ticker, b.Ticker), 156 | ) 157 | }) 158 | 159 | err = t.Execute(w, pData) 160 | if err != nil { 161 | http.Error(w, err.Error(), 500) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /ledger/cmd/webLoadTemplate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "html/template" 6 | "path" 7 | "strings" 8 | 9 | "github.com/juztin/numeronym" 10 | ) 11 | 12 | func abbrev(acctName string) string { 13 | accounts := strings.Split(acctName, ":") 14 | shortAccounts := make([]string, len(accounts)) 15 | for i := range accounts[:len(accounts)-1] { 16 | shortAccounts[i] = string(numeronym.Parse([]byte(accounts[i]))) 17 | } 18 | shortAccounts[len(accounts)-1] = accounts[len(accounts)-1] 19 | return strings.Join(shortAccounts, ":") 20 | } 21 | 22 | func lastaccount(acctName string) string { 23 | accounts := strings.Split(acctName, ":") 24 | return accounts[len(accounts)-1] 25 | } 26 | 27 | func qvshortname(accname string) string { 28 | for _, qvc := range quickviewConfigData.Accounts { 29 | if qvc.Name == accname { 30 | return qvc.ShortName 31 | } 32 | } 33 | return abbrev(accname) 34 | } 35 | 36 | func loadTemplates(filenames ...string) (*template.Template, error) { 37 | if len(filenames) == 0 { 38 | // Not really a problem, but be consistent. 39 | return nil, errors.New("html/template: no files named in call to ParseFiles") 40 | } 41 | funcMap := template.FuncMap{ 42 | "abbrev": abbrev, 43 | "lastaccount": lastaccount, 44 | "qvshortname": qvshortname, 45 | "substr": strings.Contains, 46 | } 47 | 48 | filenames = append(filenames, "templates/template.common.html") 49 | return template.New(path.Base(filenames[0])).Funcs(funcMap).ParseFS(contentTemplates, filenames...) 50 | } 51 | -------------------------------------------------------------------------------- /ledger/genprofile.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | for i in $(seq 1 10); 4 | do 5 | ./ledger --prof "bal$i.pprof" bal > /dev/null 6 | ./ledger --prof "reg$i.pprof" reg > /dev/null 7 | ./ledger --prof "print$i.pprof" print > /dev/null 8 | ./ledger --prof "stats$i.pprof" stats > /dev/null 9 | done 10 | 11 | rm default.pgo 12 | 13 | go tool pprof -proto reg{1..10}.pprof bal{1..10}.pprof print{1..10}.pprof stats{1..10}.pprof > default.pgo 14 | 15 | rm bal{1..10}.pprof 16 | rm reg{1..10}.pprof 17 | rm print{1..10}.pprof 18 | rm stats{1..10}.pprof 19 | -------------------------------------------------------------------------------- /ledger/internal/fastcolor/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Fatih Arslan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /ledger/internal/fastcolor/fastcolor.go: -------------------------------------------------------------------------------- 1 | // Package fastcolor is an extreme subset of the fatih/color package to get 2 | // ANSI colors on standard output. 3 | // 4 | // Modified to output the color string to a StringWriter with fixed-width 5 | // formatting (spaces for padding). Minimal color and attribute support. 6 | package fastcolor 7 | 8 | import ( 9 | "io" 10 | "os" 11 | "strings" 12 | "unicode/utf8" 13 | 14 | "github.com/mattn/go-isatty" 15 | ) 16 | 17 | type Color string 18 | 19 | const ( 20 | Reset Color = "0" 21 | Bold Color = "1" 22 | FgBlack Color = "30" 23 | FgRed Color = "31" 24 | FgGreen Color = "32" 25 | FgYellow Color = "33" 26 | FgBlue Color = "34" 27 | FgMagenta Color = "35" 28 | FgCyan Color = "36" 29 | FgWhite Color = "37" 30 | ) 31 | 32 | var spaceStr string = strings.Repeat(" ", 132) 33 | 34 | // NoColor defines if the output is colorized or not. It's dynamically set to 35 | // false or true based on the stdout's file descriptor referring to a terminal 36 | // or not. It's also set to true if the NO_COLOR environment variable is 37 | // set (regardless of its value). This is a global option and affects all 38 | // colors. 39 | var NoColor = noColorIsSet() || os.Getenv("TERM") == "dumb" || 40 | (!isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd())) 41 | 42 | // noColorIsSet returns true if the environment variable NO_COLOR is set to a non-empty string. 43 | func noColorIsSet() bool { 44 | return os.Getenv("NO_COLOR") != "" 45 | } 46 | 47 | func (c Color) WriteStringFixed(w io.StringWriter, s string, width int, leftpad bool) { 48 | if !NoColor { 49 | w.WriteString("\x1b[") 50 | w.WriteString(string(c)) 51 | w.WriteString("m") 52 | } 53 | 54 | l := utf8.RuneCountInString(s) 55 | spaces := width - l 56 | if spaces > 0 { 57 | if leftpad { 58 | w.WriteString(spaceStr[:spaces]) 59 | w.WriteString(s) 60 | } else { 61 | w.WriteString(s) 62 | w.WriteString(spaceStr[:spaces]) 63 | } 64 | } else { 65 | w.WriteString(s[:width]) 66 | } 67 | 68 | if !NoColor { 69 | w.WriteString("\x1b[0m") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ledger/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/howeyc/ledger/ledger/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /ledger/man/ledger.5: -------------------------------------------------------------------------------- 1 | .Dd June 25, 2021 2 | .Dt LEDGER 5 3 | .Os 4 | .Sh NAME 5 | .Nm ledger 6 | .Nd transaction file 7 | .Pp 8 | .Sh DESCRIPTION 9 | .Pp 10 | A ledger transaction file is a list of transactions separated by at-least one 11 | blank line. Each transactions consists of a date, payee, and two or more 12 | postings. A posting is an indented line, consisting of an account, and 13 | optionally, a value. Only one posting can be without value a value for each 14 | transaction. The blank value posting receives the negated sum of all other 15 | postings in the transaction. 16 | .Pp 17 | A single space is used as a delimeter between date and payee. Payee is the rest 18 | of the transaction header line and can contain spaces. 19 | Comments begin with ";" and continue for the rest of the line. Comments on 20 | their own line attach to the next transaction in the file. 21 | .Pp 22 | Posting lines for accounts in a transaction must have at-least one whitespace 23 | character at the start of the line (usually tab). To allow for accounts in 24 | postings to contain spaces, at-least two (or more) whitespace characters are 25 | required separate the value from the account. 26 | .Pp 27 | .Sh FORMAT 28 | .Pp 29 | Format of a transaction: 30 | .Pp 31 | .nf 32 | .RS 4 33 | YYYY/mm/dd 34 | 35 | 36 | .fi 37 | .RE 38 | .Pp 39 | .Sh EXAMPLES 40 | .Pp 41 | Example transactions: 42 | .Pp 43 | .nf 44 | .RS 4 45 | ; Had ice cream 46 | 2021/06/27 Dairy Queen 47 | Assets:Wallet -10.56 ; Used cash 48 | Expenses:Food 10.56 49 | 50 | 2021/06/27 Cineplex 51 | Liabilities:Credit Card:American Express 52 | Expenses:Entertainment:Movies 23.40 53 | .fi 54 | .RE 55 | .Pp 56 | .Sh ACCOUNTS 57 | .Pp 58 | Accounts use ":" as a separator for hierarchy. All sub-accounts combine up to 59 | the top account. 60 | .Pp 61 | In the above two transactions, Expenses will be 43.96. 62 | .Pp 63 | .Sh SEE ALSO 64 | .Xr ledger 1 65 | .Sh AUTHORS 66 | .An "Chris Howey" 67 | .Aq chris@howey.me 68 | -------------------------------------------------------------------------------- /ledgerReader.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // NewLedgerReader reads a file and includes any files with include directives 10 | // and returns the whole combined ledger as a buffer for parsing. 11 | // 12 | // Deprecated: use ParseLedgerFile 13 | func NewLedgerReader(filename string) (io.Reader, error) { 14 | var buf bytes.Buffer 15 | 16 | ifile, ierr := os.Open(filename) 17 | if ierr != nil { 18 | return &buf, ierr 19 | } 20 | _, cerr := io.Copy(&buf, ifile) 21 | ifile.Close() 22 | 23 | return &buf, cerr 24 | } 25 | -------------------------------------------------------------------------------- /ledgerReader_test.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestReaderSimple(t *testing.T) { 8 | _, err := NewLedgerReader("testdata/ledgerRoot.dat") 9 | if err != nil { 10 | t.Fatal(err) 11 | } 12 | } 13 | 14 | func TestReaderNonExistant(t *testing.T) { 15 | _, err := NewLedgerReader("testdata/ledger-xxxxx.dat") 16 | if err.Error() != "open testdata/ledger-xxxxx.dat: no such file or directory" { 17 | t.Fatal(err) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /linescanner.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | "unsafe" 8 | ) 9 | 10 | type linescanner struct { 11 | scanner *bufio.Scanner 12 | unsafe bool 13 | 14 | filename string 15 | lineCount int 16 | } 17 | 18 | // NewLineScanner creates a wrapper around bufio.Scanner with pre-allocated 19 | // buffer. Significantly reduces memory allocations and reduces runtime. 20 | func newLineScanner(filename string, r io.Reader) *linescanner { 21 | lp := &linescanner{} 22 | lp.scanner = bufio.NewScanner(r) 23 | if fs, fserr := os.Stat(filename); fserr == nil { 24 | lp.scanner.Buffer(make([]byte, int(fs.Size())), int(fs.Size())) 25 | lp.unsafe = true 26 | } 27 | lp.filename = filename 28 | 29 | return lp 30 | } 31 | 32 | func (lp *linescanner) Scan() bool { 33 | return lp.scanner.Scan() 34 | } 35 | 36 | func (lp *linescanner) Text() string { 37 | var line string 38 | if lp.unsafe { 39 | if lbytes := lp.scanner.Bytes(); len(lbytes) > 0 { 40 | line = unsafe.String(unsafe.SliceData(lbytes), len(lbytes)) 41 | } else { 42 | line = "" 43 | } 44 | } else { 45 | line = lp.scanner.Text() 46 | } 47 | lp.lineCount++ 48 | return line 49 | } 50 | 51 | func (lp *linescanner) LineNumber() int { 52 | return lp.lineCount 53 | } 54 | 55 | func (lp *linescanner) Name() string { 56 | return lp.filename 57 | } 58 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howeyc/ledger/a9ce1afd13dc9503c8fd9d835edca8e8b80b991e/logo.png -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "sync" 11 | "time" 12 | "unicode" 13 | 14 | "github.com/alfredxing/calc/compute" 15 | "github.com/howeyc/ledger/decimal" 16 | date "github.com/joyt/godate" 17 | ) 18 | 19 | // ParseLedgerFile parses a ledger file and returns a list of Transactions. 20 | func ParseLedgerFile(filename string) (generalLedger []*Transaction, err error) { 21 | ifile, ierr := os.Open(filename) 22 | if ierr != nil { 23 | return nil, ierr 24 | } 25 | defer ifile.Close() 26 | var mu sync.Mutex 27 | parseLedger(filename, ifile, func(t []*Transaction, e error) (stop bool) { 28 | if e != nil { 29 | err = e 30 | stop = true 31 | return 32 | } 33 | 34 | mu.Lock() 35 | generalLedger = append(generalLedger, t...) 36 | mu.Unlock() 37 | return 38 | }) 39 | 40 | return 41 | } 42 | 43 | // ParseLedger parses a ledger file and returns a list of Transactions. 44 | func ParseLedger(ledgerReader io.Reader) (generalLedger []*Transaction, err error) { 45 | parseLedger("", ledgerReader, func(t []*Transaction, e error) (stop bool) { 46 | if e != nil { 47 | err = e 48 | stop = true 49 | return 50 | } 51 | 52 | generalLedger = append(generalLedger, t...) 53 | return 54 | }) 55 | 56 | return 57 | } 58 | 59 | // ParseLedgerAsync parses a ledger file and returns a Transaction and error channels . 60 | func ParseLedgerAsync(ledgerReader io.Reader) (c chan *Transaction, e chan error) { 61 | c = make(chan *Transaction) 62 | e = make(chan error) 63 | 64 | go func() { 65 | parseLedger("", ledgerReader, func(tlist []*Transaction, err error) (stop bool) { 66 | if err != nil { 67 | e <- err 68 | } else { 69 | for _, t := range tlist { 70 | c <- t 71 | } 72 | } 73 | return 74 | }) 75 | 76 | e <- nil 77 | close(c) 78 | close(e) 79 | }() 80 | return c, e 81 | } 82 | 83 | type parser struct { 84 | scanner *linescanner 85 | 86 | comments []string 87 | dateLayout string 88 | 89 | strPrevDate string 90 | prevDateErr error 91 | prevDate time.Time 92 | 93 | transactions []Transaction 94 | ctIdx int 95 | postings []Account 96 | cpIdx int 97 | } 98 | 99 | const preAllocSize = 100000 100 | const preAllocWarn = 10 101 | 102 | func (p *parser) init() { 103 | p.transactions = make([]Transaction, preAllocSize) 104 | p.postings = make([]Account, preAllocSize*3) 105 | p.ctIdx = 0 106 | p.cpIdx = 0 107 | } 108 | 109 | func (p *parser) grow() { 110 | if len(p.transactions)-p.ctIdx < preAllocWarn || 111 | len(p.postings)-p.cpIdx < (preAllocWarn*3) { 112 | p.init() 113 | } 114 | } 115 | 116 | func parseLedger(filename string, ledgerReader io.Reader, callback func(t []*Transaction, err error) (stop bool)) (stop bool) { 117 | var lp parser 118 | lp.init() 119 | lp.scanner = newLineScanner(filename, ledgerReader) 120 | 121 | var tlist []*Transaction 122 | 123 | for lp.scanner.Scan() { 124 | // remove heading and tailing space from the line 125 | trimmedLine := strings.TrimSpace(lp.scanner.Text()) 126 | 127 | var currentComment string 128 | // handle comments 129 | if commentIdx := strings.Index(trimmedLine, ";"); commentIdx >= 0 { 130 | currentComment = trimmedLine[commentIdx:] 131 | trimmedLine = trimmedLine[:commentIdx] 132 | trimmedLine = strings.TrimSpace(trimmedLine) 133 | } 134 | 135 | // Skip empty lines 136 | if len(trimmedLine) == 0 { 137 | if len(currentComment) > 0 { 138 | lp.comments = append(lp.comments, currentComment) 139 | } 140 | continue 141 | } 142 | 143 | before, after, split := strings.Cut(trimmedLine, " ") 144 | if !split { 145 | if callback(nil, fmt.Errorf("%s:%d: unable to parse transaction: %w", lp.scanner.Name(), lp.scanner.LineNumber(), 146 | fmt.Errorf("unable to parse payee line: %s", trimmedLine))) { 147 | return true 148 | } 149 | if len(currentComment) > 0 { 150 | lp.comments = append(lp.comments, currentComment) 151 | } 152 | continue 153 | } 154 | switch before { 155 | case "account": 156 | lp.skipAccount() 157 | case "include": 158 | paths, _ := filepath.Glob(filepath.Join(filepath.Dir(lp.scanner.Name()), after)) 159 | if len(paths) < 1 { 160 | callback(nil, fmt.Errorf("%s:%d: unable to include file(%s): %w", lp.scanner.Name(), lp.scanner.LineNumber(), after, errors.New("not found"))) 161 | return true 162 | } 163 | var wg sync.WaitGroup 164 | for _, incpath := range paths { 165 | wg.Add(1) 166 | go func(ipath string) { 167 | ifile, _ := os.Open(ipath) 168 | defer ifile.Close() 169 | if parseLedger(ipath, ifile, callback) { 170 | stop = true 171 | } 172 | wg.Done() 173 | }(incpath) 174 | } 175 | wg.Wait() 176 | if stop { 177 | return stop 178 | } 179 | default: 180 | trans, transErr := lp.parseTransaction(before, after, currentComment) 181 | if transErr != nil { 182 | if callback(nil, fmt.Errorf("%s:%d: unable to parse transaction: %w", lp.scanner.Name(), lp.scanner.LineNumber(), transErr)) { 183 | return true 184 | } 185 | continue 186 | } 187 | tlist = append(tlist, trans) 188 | } 189 | } 190 | callback(tlist, nil) 191 | return false 192 | } 193 | 194 | func (lp *parser) skipAccount() { 195 | for lp.scanner.Scan() { 196 | // Read until blank line (ignore all sub-directives) 197 | if len(lp.scanner.Text()) == 0 { 198 | return 199 | } 200 | } 201 | } 202 | 203 | func (lp *parser) parseDate(dateString string) (transDate time.Time, err error) { 204 | // seen before, skip parse 205 | if lp.strPrevDate == dateString { 206 | return lp.prevDate, lp.prevDateErr 207 | } 208 | 209 | // try current date layout 210 | transDate, err = time.Parse(lp.dateLayout, dateString) 211 | if err != nil { 212 | // try to find new date layout 213 | transDate, lp.dateLayout, err = date.ParseAndGetLayout(dateString) 214 | if err != nil { 215 | err = fmt.Errorf("unable to parse date(%s): %w", dateString, err) 216 | } 217 | } 218 | 219 | // maybe next date is same 220 | lp.strPrevDate = dateString 221 | lp.prevDate = transDate 222 | lp.prevDateErr = err 223 | 224 | return 225 | } 226 | 227 | func (lp *parser) parseTransaction(dateString, payeeString, payeeComment string) (trans *Transaction, err error) { 228 | transDate, derr := lp.parseDate(dateString) 229 | if derr != nil { 230 | return nil, derr 231 | } 232 | 233 | transBal := decimal.Zero 234 | var numEmpty int 235 | var emptyAccIndex int 236 | var accIndex int 237 | 238 | for lp.scanner.Scan() { 239 | trimmedLine := lp.scanner.Text() 240 | 241 | // handle comments 242 | if commentIdx := strings.Index(trimmedLine, ";"); commentIdx >= 0 { 243 | currentComment := trimmedLine[commentIdx:] 244 | trimmedLine = trimmedLine[:commentIdx] 245 | trimmedLine = strings.TrimSpace(trimmedLine) 246 | if len(trimmedLine) == 0 { 247 | lp.comments = append(lp.comments, currentComment) 248 | continue 249 | } 250 | lp.postings[lp.cpIdx+accIndex].Comment = currentComment 251 | } 252 | 253 | if len(trimmedLine) == 0 { 254 | break 255 | } 256 | 257 | if iSpace := strings.LastIndexFunc(trimmedLine, unicode.IsSpace); iSpace >= 0 { 258 | if decbal, derr := decimal.NewFromString(trimmedLine[iSpace+1:]); derr == nil { 259 | lp.postings[lp.cpIdx+accIndex].Name = strings.TrimSpace(trimmedLine[:iSpace]) 260 | lp.postings[lp.cpIdx+accIndex].Balance = decbal 261 | } else if iParen := strings.Index(trimmedLine, "("); iParen >= 0 { 262 | lp.postings[lp.cpIdx+accIndex].Name = strings.TrimSpace(trimmedLine[:iParen]) 263 | f, _ := compute.Evaluate(trimmedLine[iParen+1 : len(trimmedLine)-1]) 264 | lp.postings[lp.cpIdx+accIndex].Balance = decimal.NewFromFloat(f) 265 | } else { 266 | lp.postings[lp.cpIdx+accIndex].Name = strings.TrimSpace(trimmedLine) 267 | } 268 | } else { 269 | lp.postings[lp.cpIdx+accIndex].Name = strings.TrimSpace(trimmedLine) 270 | } 271 | 272 | if lp.postings[lp.cpIdx+accIndex].Balance.IsZero() { 273 | numEmpty++ 274 | emptyAccIndex = accIndex 275 | } 276 | transBal = transBal.Add(lp.postings[lp.cpIdx+accIndex].Balance) 277 | accIndex++ 278 | } 279 | 280 | if accIndex < 2 { 281 | err = errors.New("need at least two postings") 282 | return 283 | } 284 | 285 | if !transBal.IsZero() { 286 | switch numEmpty { 287 | case 0: 288 | return nil, errors.New("unable to balance transaction: no empty account to place extra balance") 289 | case 1: 290 | // If there is a single empty account, then it is obvious where to 291 | // place the remaining balance. 292 | lp.postings[lp.cpIdx+emptyAccIndex].Balance = transBal.Neg() 293 | default: 294 | return nil, errors.New("unable to balance transaction: more than one account empty") 295 | } 296 | } 297 | 298 | lp.transactions[lp.ctIdx].Payee = payeeString 299 | lp.transactions[lp.ctIdx].Date = transDate 300 | lp.transactions[lp.ctIdx].PayeeComment = payeeComment 301 | lp.transactions[lp.ctIdx].AccountChanges = lp.postings[lp.cpIdx : lp.cpIdx+accIndex] 302 | lp.transactions[lp.ctIdx].Comments = lp.comments 303 | 304 | trans = &lp.transactions[lp.ctIdx] 305 | 306 | lp.comments = nil 307 | lp.cpIdx += accIndex 308 | lp.ctIdx++ 309 | 310 | lp.grow() 311 | 312 | return 313 | } 314 | -------------------------------------------------------------------------------- /parseFuzz_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package ledger 4 | 5 | import ( 6 | "bytes" 7 | "testing" 8 | 9 | "github.com/howeyc/ledger/decimal" 10 | ) 11 | 12 | func FuzzParseLedger(f *testing.F) { 13 | for _, tc := range testCases { 14 | if tc.err == nil { 15 | f.Add(tc.data) 16 | } 17 | } 18 | f.Fuzz(func(t *testing.T, s string) { 19 | b := bytes.NewBufferString(s) 20 | trans, _ := ParseLedger(b) 21 | overall := decimal.Zero 22 | for _, t := range trans { 23 | for _, p := range t.AccountChanges { 24 | overall = overall.Add(p.Balance) 25 | } 26 | } 27 | if !overall.IsZero() { 28 | t.Error("Bad balance") 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /testdata/ledger-2021-05.dat: -------------------------------------------------------------------------------- 1 | 2022/04/01 Payee 2 | Assets:Wallet 5 3 | Expenses:Food 4 | 5 | 2022/04/01 Payee 6 | Assets:Wallet 5 7 | Expenses:Food 8 | 9 | 2022/04/01 Payee 10 | Assets:Wallet 10 11 | Expenses:Food -8 12 | 13 | 2022/04/01 Payee 14 | Assets:Wallet 10 15 | Expenses:Food 16 | 17 | -------------------------------------------------------------------------------- /testdata/ledger-2022-01.dat: -------------------------------------------------------------------------------- 1 | 2022/01/01 Payee 2 | Assets:Wallet 5 3 | Expenses:Food 4 | 5 | 2022/01/01 Payee 6 | Assets:Wallet 5 7 | Expenses:Food 8 | 9 | 2022/01/01 Payee 10 | Assets:Wallet 5 11 | Expenses:Food 12 | 13 | 2022/01/01 Payee 14 | Assets:Wallet 5 15 | Expenses:Food 16 | 17 | -------------------------------------------------------------------------------- /testdata/ledger-2022-02.dat: -------------------------------------------------------------------------------- 1 | 2022/02/01 Payee 2 | Assets:Wallet 5 3 | Expenses:Food 4 | 5 | 2022/02/01 Payee 6 | Assets:Wallet 5 7 | Expenses:Food 8 | 9 | 2022/02/01 Payee 10 | Assets:Wallet 5 11 | Expenses:Food 12 | 13 | 2022/02/01 Payee 14 | Assets:Wallet 5 15 | Expenses:Food 16 | 17 | -------------------------------------------------------------------------------- /testdata/ledger-2022-04.dat: -------------------------------------------------------------------------------- 1 | 2022/04/01 Payee 2 | Assets:Wallet 5 3 | Expenses:Food 4 | 5 | 2022/04/01 Payee 6 | Assets:Wallet 5 7 | Expenses:Food 8 | 9 | 2022/04/01 Payee 10 | Assets:Wallet 10 11 | Expenses:Food 12 | 13 | 2022/04/01 Payee 14 | Assets:Wallet 10 15 | Expenses:Food 16 | 17 | -------------------------------------------------------------------------------- /testdata/ledgerBench.dat: -------------------------------------------------------------------------------- 1 | 2022/01/01 Payee 2 | Assets:Wallet 5.00 3 | Expenses:Food -5.00 4 | 5 | 2022/01/01 Payee 6 | Assets:Wallet 5.00 7 | Expenses:Food -5.00 8 | 9 | 2022/01/01 Payee 10 | Assets:Wallet 5.00 11 | Expenses:Food -5.00 12 | 13 | 2022/01/01 Payee 14 | Assets:Wallet 5.00 15 | Expenses:Food -5.00 16 | 17 | 2022/02/01 Payee 18 | Assets:Wallet 5.00 19 | Expenses:Food -5.00 20 | 21 | 2022/02/01 Payee 22 | Assets:Wallet 5.00 23 | Expenses:Food -5.00 24 | 25 | 2022/02/01 Payee 26 | Assets:Wallet 5.00 27 | Expenses:Food -5.00 28 | 29 | 2022/02/01 Payee 30 | Assets:Wallet 5.00 31 | Expenses:Food -5.00 32 | 33 | 2022/03/01 Payee 34 | Assets:Wallet 5.00 35 | Expenses:Food -5.00 36 | 37 | 2022/03/01 Payee 38 | Assets:Wallet 5.00 39 | Expenses:Food -5.00 40 | 41 | 2022/04/01 Payee 42 | Assets:Wallet 5.00 43 | Expenses:Food -5.00 44 | 45 | 2022/04/01 Payee 46 | Assets:Wallet 5.00 47 | Expenses:Food -5.00 48 | 49 | 2022/04/01 Payee 50 | Assets:Wallet 10.00 51 | Expenses:Food -10.00 52 | 53 | 2022/04/01 Payee 54 | Assets:Wallet 10.00 55 | Expenses:Food -10.00 56 | 57 | -------------------------------------------------------------------------------- /testdata/ledgerRoot.dat: -------------------------------------------------------------------------------- 1 | include ledger-2022-01.dat 2 | 3 | include ledger-2022-02.dat 4 | 5 | 2022/03/01 Payee 6 | Assets:Wallet 5 7 | Expenses:Food 8 | 9 | 2022/03/01 Payee 10 | Assets:Wallet 5 11 | Expenses:Food 12 | -------------------------------------------------------------------------------- /testdata/ledgerRootGlob.dat: -------------------------------------------------------------------------------- 1 | include ledger-2022-*.dat 2 | 3 | 2022/03/01 Payee 4 | Assets:Wallet 5 5 | Expenses:Food 6 | 7 | 2022/03/01 Payee 8 | Assets:Wallet 5 9 | Expenses:Food 10 | -------------------------------------------------------------------------------- /testdata/ledgerRootNonExist.dat: -------------------------------------------------------------------------------- 1 | include ledger-2022-01.dat 2 | 3 | include ledger-xxxxx.dat 4 | 5 | 2022/03/01 Payee 6 | Assets:Wallet 5 7 | Expenses:Food 8 | 9 | 2022/03/01 Payee 10 | Assets:Wallet 5 11 | Expenses:Food 12 | -------------------------------------------------------------------------------- /testdata/ledgerRootUnbalanced.dat: -------------------------------------------------------------------------------- 1 | include ledger-2021-05.dat 2 | 3 | 2022/03/01 Payee 4 | Assets:Wallet 5 5 | Expenses:Food 6 | 7 | 2022/03/01 Payee 8 | Assets:Wallet 5 9 | Expenses:Food 10 | 11 | include ledger-2021-05.dat 12 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package ledger 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/howeyc/ledger/decimal" 7 | ) 8 | 9 | // Account holds the name and balance 10 | type Account struct { 11 | Name string 12 | Balance decimal.Decimal 13 | Comment string 14 | } 15 | 16 | // Transaction is the basis of a ledger. The ledger holds a list of transactions. 17 | // A Transaction has a Payee, Date (with no time, or to put another way, with 18 | // hours,minutes,seconds values that probably doesn't make sense), and a list of 19 | // Account values that hold the value of the transaction for each account. 20 | type Transaction struct { 21 | Date time.Time 22 | Payee string 23 | PayeeComment string 24 | AccountChanges []Account 25 | Comments []string 26 | } 27 | -------------------------------------------------------------------------------- /vim-ledger/ftplugin/ledger.vim: -------------------------------------------------------------------------------- 1 | " Vim filetype plugin file 2 | " filetype: ledger 3 | " by Chris Howey 4 | 5 | setl omnifunc=LedgerComplete 6 | 7 | if !exists('g:ledger_main') 8 | let g:ledger_main = '%:p' 9 | endif 10 | 11 | if !exists('g:ledger_bin') || empty(g:ledger_bin) 12 | if executable('ledger') 13 | let g:ledger_bin = 'ledger' 14 | endif 15 | elseif !executable(g:ledger_bin) 16 | unlet! g:ledger_bin 17 | echohl WarningMsg 18 | echomsg 'Command set in g:ledger_bin is not executable' 19 | echohl None 20 | endif 21 | 22 | if !exists('g:ledger_accounts_cmd') 23 | if exists('g:ledger_bin') 24 | let g:ledger_accounts_cmd = g:ledger_bin . ' -f ' . shellescape(expand(g:ledger_main)) . ' accounts' 25 | endif 26 | endif 27 | 28 | function! LedgerComplete(findstart, base) 29 | if a:findstart 30 | let line = getline('.') 31 | let end = col('.') - 1 32 | let start = 0 33 | while start < end && line[start] =~ '\s' 34 | let start += 1 35 | endwhile 36 | return start 37 | else 38 | let res = [] 39 | for m in systemlist(g:ledger_accounts_cmd . ' -m "' . a:base . '"') 40 | call add(res, m) 41 | endfor 42 | return res 43 | endif 44 | endfun 45 | 46 | function! _LedgerFormatFile() 47 | if exists('g:ledger_bin') && exists('g:ledger_autofmt_bufwritepre') && g:ledger_autofmt_bufwritepre 48 | let substitution = system(g:ledger_bin . ' print -f -', join(getline(1, line('$')), "\n")) 49 | if v:shell_error != 0 50 | echoerr "While formatting the buffer via fmt, the following error occurred:" 51 | echoerr printf("ERROR(%d): %s", v:shell_error, substitution) 52 | else 53 | let [_, lnum, colnum, _] = getpos('.') 54 | %delete 55 | call setline(1, split(substitution, "\n")) 56 | call cursor(lnum, colnum) 57 | endif 58 | endif 59 | endfunction 60 | 61 | if has('autocmd') 62 | augroup ledger_fmt 63 | autocmd BufWritePre * call _LedgerFormatFile() 64 | augroup END 65 | endif 66 | 67 | " show payee line and amount as fold header 68 | if has('folding') 69 | function! LedgerFoldText() 70 | let line = getline(v:foldstart) 71 | let cmt = matchstr(line, ' ;.*') 72 | let sidx = stridx(line, " ") 73 | if sidx > 0 74 | let line = strpart(line, 0, sidx) 75 | endif 76 | let amt = matchstr(getline(v:foldstart+1), '-\?\d\+\.\d\+') 77 | let blanks = repeat(' ', 80-(len(line)+len(amt))) 78 | return line .. blanks .. amt .. cmt 79 | endfunction 80 | 81 | setlocal foldtext=LedgerFoldText() 82 | 83 | " foldexpr to use blank lines to separate folds 84 | setlocal foldexpr=getline(v:lnum)=~'^\\s*$'&&getline(v:lnum+1)=~'\\S'?'<1':1 85 | endif 86 | 87 | " Commands for ledger file type: 88 | " insert date 89 | nnoremap id "=strftime("%Y/%m/%d")P 90 | " delete posting amount 91 | nnoremap da $BbelD 92 | -------------------------------------------------------------------------------- /vim-ledger/syntax/ledger.vim: -------------------------------------------------------------------------------- 1 | " Vim syntax file 2 | " filetype: ledger 3 | " by Chris Howey 4 | 5 | syn match ledgerComment /;.*/ 6 | 7 | syn match ledgerAmount /\s\{2}-\?\d\+\.\d\+$/ms=s+2 8 | syn match ledgerAccount /^\s\{4}.*\s\{2}/ms=s+4,me=e-2 9 | 10 | syn match ledgerDate /^\d\{4}\%(\/\|-\)\d\{2}\%(\/\|-\)\d\{2}\s/me=e-1 11 | 12 | syn region ledgerFold start="^\S" end="^$" transparent fold 13 | 14 | highlight default link ledgerDate Function 15 | highlight default link ledgerComment Comment 16 | highlight default link ledgerAmount Number 17 | highlight default link ledgerAccount Identifier 18 | --------------------------------------------------------------------------------