Skip to content

Commit

Permalink
add income statement
Browse files Browse the repository at this point in the history
  • Loading branch information
ananthakumaran committed Nov 26, 2023
1 parent 74efb4b commit 4d87445
Show file tree
Hide file tree
Showing 22 changed files with 1,025 additions and 28 deletions.
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ hide:

<div class="features-container" markdown>
<div class="features" markdown>
- :material-file-table: Builds on top of the **[ledger](https://www.ledger-cli.org/)** double entry accounting tool.
- :fontawesome-regular-file-lines: Builds on top of the **[ledger](https://www.ledger-cli.org/)** double entry accounting tool.
- :simple-gnuprivacyguard: Your financial **data** never leaves your system.
- :simple-git: The journal and configuration information are stored in **plain text** files
that can be easily version controlled. You can collaborate with
Expand Down
145 changes: 145 additions & 0 deletions internal/server/income_statement.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package server

import (
"sort"
"strings"
"time"

"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/query"
"github.com/ananthakumaran/paisa/internal/service"
"github.com/ananthakumaran/paisa/internal/utils"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)

type IncomeStatement struct {
StartingBalance decimal.Decimal `json:"startingBalance"`
EndingBalance decimal.Decimal `json:"endingBalance"`
Date time.Time `json:"date"`
Income map[string]decimal.Decimal `json:"income"`
Interest map[string]decimal.Decimal `json:"interest"`
Equity map[string]decimal.Decimal `json:"equity"`
Pnl map[string]decimal.Decimal `json:"pnl"`
Liabilities map[string]decimal.Decimal `json:"liabilities"`
Tax map[string]decimal.Decimal `json:"tax"`
Expenses map[string]decimal.Decimal `json:"expenses"`
}

type RunningBalance struct {
amount decimal.Decimal
quantity map[string]decimal.Decimal
}

func GetIncomeStatement(db *gorm.DB) gin.H {
postings := query.Init(db).All()
statements := computeStatement(db, postings)
return gin.H{"yearly": statements}
}

func computeStatement(db *gorm.DB, postings []posting.Posting) map[string]IncomeStatement {
statements := make(map[string]IncomeStatement)

grouped := utils.GroupByFY(postings)
fys := lo.Keys(grouped)
sort.Strings(fys)

runnings := make(map[string]RunningBalance)
startingBalance := decimal.Zero

for _, fy := range fys {
incomeStatement := IncomeStatement{}
start, end := utils.ParseFY(fy)
incomeStatement.Date = start
incomeStatement.StartingBalance = startingBalance
incomeStatement.Income = make(map[string]decimal.Decimal)
incomeStatement.Interest = make(map[string]decimal.Decimal)
incomeStatement.Equity = make(map[string]decimal.Decimal)
incomeStatement.Pnl = make(map[string]decimal.Decimal)
incomeStatement.Liabilities = make(map[string]decimal.Decimal)
incomeStatement.Tax = make(map[string]decimal.Decimal)
incomeStatement.Expenses = make(map[string]decimal.Decimal)

for _, p := range grouped[fy] {

category := utils.FirstName(p.Account)

switch category {
case "Income":
if service.IsCapitalGains(p) {
sourceAccount := service.CapitalGainsSourceAccount(p.Account)
r := runnings[sourceAccount]
if r.quantity == nil {
r.quantity = make(map[string]decimal.Decimal)
}
r.amount = r.amount.Add(p.Amount)
runnings[sourceAccount] = r
} else if strings.HasPrefix(p.Account, "Income:Interest") {
incomeStatement.Interest[p.Account] = incomeStatement.Interest[p.Account].Add(p.Amount)
} else {
incomeStatement.Income[p.Account] = incomeStatement.Income[p.Account].Add(p.Amount)
}
case "Equity":
incomeStatement.Equity[p.Account] = incomeStatement.Equity[p.Account].Add(p.Amount)
case "Expenses":
if strings.HasPrefix(p.Account, "Expenses:Tax") {
incomeStatement.Tax[p.Account] = incomeStatement.Tax[p.Account].Add(p.Amount)
} else {
incomeStatement.Expenses[p.Account] = incomeStatement.Expenses[p.Account].Add(p.Amount)
}
case "Liabilities":
incomeStatement.Liabilities[p.Account] = incomeStatement.Liabilities[p.Account].Add(p.Amount)
case "Assets":
r := runnings[p.Account]
if r.quantity == nil {
r.quantity = make(map[string]decimal.Decimal)
}
r.amount = r.amount.Add(p.Amount)
r.quantity[p.Commodity] = r.quantity[p.Commodity].Add(p.Quantity)
runnings[p.Account] = r
default:
// ignore
}
}

for account, r := range runnings {
diff := r.amount.Neg()
for commodity, quantity := range r.quantity {
diff = diff.Add(service.GetPrice(db, commodity, quantity, end))
}
incomeStatement.Pnl[account] = diff

r.amount = r.amount.Add(diff)
runnings[account] = r
}

startingBalance = startingBalance.
Add(sumBalance(incomeStatement.Income).Neg()).
Add(sumBalance(incomeStatement.Interest).Neg()).
Add(sumBalance(incomeStatement.Equity).Neg()).
Add(sumBalance(incomeStatement.Tax).Neg()).
Add(sumBalance(incomeStatement.Expenses).Neg()).
Add(sumBalance(incomeStatement.Pnl)).
Add(sumBalance(incomeStatement.Liabilities).Neg())

incomeStatement.EndingBalance = startingBalance

statements[fy] = incomeStatement
}

return statements
}

func sumBalance(breakdown map[string]decimal.Decimal) decimal.Decimal {
total := decimal.Zero
for k, v := range breakdown {
total = total.Add(v)

if v.Equal(decimal.Zero) {
delete(breakdown, k)
}
}
return total
}
3 changes: 3 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ func Build(db *gorm.DB, enableCompression bool) *gin.Engine {
router.GET("/api/cash_flow", func(c *gin.Context) {
c.JSON(200, GetCashFlow(db))
})
router.GET("/api/income_statement", func(c *gin.Context) {
c.JSON(200, GetIncomeStatement(db))
})
router.GET("/api/recurring", func(c *gin.Context) {
c.JSON(200, GetRecurringTransactions(db))
})
Expand Down
79 changes: 63 additions & 16 deletions internal/service/market.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package service

import (
"sort"
"sync"
"time"

Expand All @@ -17,7 +18,8 @@ import (

type priceCache struct {
sync.Once
pricesTree map[string]*btree.BTree
pricesTree map[string]*btree.BTree
postingPricesTree map[string]*btree.BTree
}

var pcache priceCache
Expand All @@ -29,6 +31,7 @@ func loadPriceCache(db *gorm.DB) {
log.Fatal(result.Error)
}
pcache.pricesTree = make(map[string]*btree.BTree)
pcache.postingPricesTree = make(map[string]*btree.BTree)

for _, price := range prices {
if pcache.pricesTree[price.CommodityName] == nil {
Expand All @@ -45,15 +48,20 @@ func loadPriceCache(db *gorm.DB) {
}

for commodityName, postings := range lo.GroupBy(postings, func(p posting.Posting) string { return p.Commodity }) {
if !utils.IsCurrency(postings[0].Commodity) && pcache.pricesTree[commodityName] == nil {
if !utils.IsCurrency(postings[0].Commodity) {
result := db.Where("commodity_type = ? and commodity_name = ?", config.Unknown, commodityName).Find(&prices)
if result.Error != nil {
log.Fatal(result.Error)
}

pcache.pricesTree[commodityName] = btree.New(2)
postingPricesTree := btree.New(2)
for _, price := range prices {
pcache.pricesTree[price.CommodityName].ReplaceOrInsert(price)
postingPricesTree.ReplaceOrInsert(price)
}
pcache.postingPricesTree[commodityName] = postingPricesTree

if pcache.pricesTree[commodityName] == nil {
pcache.pricesTree[commodityName] = postingPricesTree
}
}
}
Expand All @@ -71,39 +79,78 @@ func GetUnitPrice(db *gorm.DB, commodity string, date time.Time) price.Price {
log.Fatal("Price not found ", commodity)
}

pc := utils.BTreeDescendFirstLessOrEqual(pt, price.Price{Date: date})
if !pc.Value.Equal(decimal.Zero) {
return pc
}

pt = pcache.postingPricesTree[commodity]
if pt == nil {
log.Fatal("Price not found ", commodity)
}
return utils.BTreeDescendFirstLessOrEqual(pt, price.Price{Date: date})

}

func GetAllPrices(db *gorm.DB, commodity string) []price.Price {
pcache.Do(func() { loadPriceCache(db) })

pt := pcache.pricesTree[commodity]
pt := pcache.postingPricesTree[commodity]
if pt == nil {
log.Fatal("Price not found ", commodity)
}

pmap := make(map[string]price.Price)

for _, price := range utils.BTreeToSlice[price.Price](pt) {
pmap[price.Date.String()] = price
}

pt = pcache.pricesTree[commodity]
if pt == nil {
log.Fatal("Price not found ", commodity)
}
return utils.BTreeToSlice[price.Price](pt)

for _, price := range utils.BTreeToSlice[price.Price](pt) {
pmap[price.Date.String()] = price
}

prices := []price.Price{}
keys := lo.Keys(pmap)
sort.Sort(sort.Reverse(sort.StringSlice(keys)))
for _, key := range keys {
prices = append(prices, pmap[key])
}

return prices
}

func GetMarketPrice(db *gorm.DB, p posting.Posting, date time.Time) decimal.Decimal {
pcache.Do(func() { loadPriceCache(db) })

if utils.IsCurrency(p.Commodity) {
return p.Amount
}

pt := pcache.pricesTree[p.Commodity]
if pt != nil {
pc := utils.BTreeDescendFirstLessOrEqual(pt, price.Price{Date: date})
if !pc.Value.Equal(decimal.Zero) {
return p.Quantity.Mul(pc.Value)
}
} else {
log.Info("Price not found ", p)
pc := GetUnitPrice(db, p.Commodity, date)
if !pc.Value.Equal(decimal.Zero) {
return p.Quantity.Mul(pc.Value)
}

return p.Amount
}

func GetPrice(db *gorm.DB, commodity string, quantity decimal.Decimal, date time.Time) decimal.Decimal {
if utils.IsCurrency(commodity) {
return quantity
}

pc := GetUnitPrice(db, commodity, date)
if !pc.Value.Equal(decimal.Zero) {
return quantity.Mul(pc.Value)
}

return quantity
}

func PopulateMarketPrice(db *gorm.DB, ps []posting.Posting) []posting.Posting {
date := utils.EndOfToday()
return lo.Map(ps, func(p posting.Posting, _ int) posting.Posting {
Expand Down
25 changes: 25 additions & 0 deletions internal/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ func FYHuman(date time.Time) string {
}
}

func ParseFY(fy string) (time.Time, time.Time) {
start, _ := time.Parse("2006", strings.Split(fy, " ")[0])
start = start.AddDate(0, int(config.GetConfig().FinancialYearStartingMonth-time.January), 0)
return BeginningOfFinancialYear(start), EndOfFinancialYear(start)
}

func BeginningOfFinancialYear(date time.Time) time.Time {
beginningOfMonth := BeginningOfMonth(date)
if beginningOfMonth.Month() < config.GetConfig().FinancialYearStartingMonth {
Expand Down Expand Up @@ -133,6 +139,10 @@ func IsSameOrParent(account string, comparison string) bool {
return strings.HasPrefix(account, comparison+":")
}

func FirstName(account string) string {
return strings.Split(account, ":")[0]
}

func IsParent(account string, comparison string) bool {
return strings.HasPrefix(account, comparison+":")
}
Expand Down Expand Up @@ -161,6 +171,21 @@ type GroupableByDate interface {
GroupDate() time.Time
}

func GroupByDate[G GroupableByDate](groupables []G) map[string][]G {
grouped := make(map[string][]G)
for _, g := range groupables {
key := g.GroupDate().Format("2006-01-02")
ps, ok := grouped[key]
if ok {
grouped[key] = append(ps, g)
} else {
grouped[key] = []G{g}
}

}
return grouped
}

func GroupByMonth[G GroupableByDate](groupables []G) map[string][]G {
grouped := make(map[string][]G)
for _, g := range groupables {
Expand Down
Loading

0 comments on commit 4d87445

Please sign in to comment.