From 4d87445bdb191bada7626a9a8b61431d5edb58cc Mon Sep 17 00:00:00 2001 From: Anantha Kumaran Date: Sat, 25 Nov 2023 23:02:20 +0530 Subject: [PATCH] add income statement --- docs/index.md | 2 +- internal/server/income_statement.go | 145 ++++++++ internal/server/server.go | 3 + internal/service/market.go | 79 ++++- internal/utils/utils.go | 25 ++ src/app.scss | 21 ++ src/lib/components/Navbar.svelte | 1 + src/lib/components/PostingCard.svelte | 2 +- src/lib/components/RecurringCard.svelte | 2 +- src/lib/components/TransactionCard.svelte | 2 +- src/lib/income_statement.ts | 323 ++++++++++++++++++ src/lib/utils.ts | 34 +- src/routes/+layout.svelte | 10 + src/routes/assets/balance/+page.svelte | 2 +- .../cash_flow/income_statement/+page.svelte | 281 +++++++++++++++ src/routes/liabilities/balance/+page.svelte | 2 +- .../fixture/eur-hledger/income_statement.json | 20 ++ tests/fixture/eur/income_statement.json | 20 ++ .../inr-beancount/income_statement.json | 26 ++ .../fixture/inr-hledger/income_statement.json | 26 ++ tests/fixture/inr/income_statement.json | 26 ++ tests/regression.test.ts | 1 + 22 files changed, 1025 insertions(+), 28 deletions(-) create mode 100644 internal/server/income_statement.go create mode 100644 src/lib/income_statement.ts create mode 100644 src/routes/cash_flow/income_statement/+page.svelte create mode 100644 tests/fixture/eur-hledger/income_statement.json create mode 100644 tests/fixture/eur/income_statement.json create mode 100644 tests/fixture/inr-beancount/income_statement.json create mode 100644 tests/fixture/inr-hledger/income_statement.json create mode 100644 tests/fixture/inr/income_statement.json diff --git a/docs/index.md b/docs/index.md index 46f20c32..1d66d498 100644 --- a/docs/index.md +++ b/docs/index.md @@ -71,7 +71,7 @@ hide:
-- :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 diff --git a/internal/server/income_statement.go b/internal/server/income_statement.go new file mode 100644 index 00000000..cd511d4e --- /dev/null +++ b/internal/server/income_statement.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index e066a0fd..055182ec 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)) }) diff --git a/internal/service/market.go b/internal/service/market.go index ef264191..0c56c02c 100644 --- a/internal/service/market.go +++ b/internal/service/market.go @@ -1,6 +1,7 @@ package service import ( + "sort" "sync" "time" @@ -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 @@ -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 { @@ -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 } } } @@ -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 { diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 01b7fa67..6c5db815 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -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 { @@ -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+":") } @@ -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 { diff --git a/src/app.scss b/src/app.scss index b14e7d21..22911d79 100644 --- a/src/app.scss +++ b/src/app.scss @@ -140,6 +140,13 @@ body { border: 1px solid rgba($grey-lightest, 0.5); } +.table.is-light-border tbody { + td, + th { + border-bottom: 1px solid rgba($grey-lightest, 0.5); + } +} + code { color: $grey-darker; } @@ -205,6 +212,10 @@ svg text { fill: $black-ter; } +.svg-text-black-bis { + fill: $black-bis; +} + .svg-text-grey-darker { fill: $grey-darker; } @@ -257,6 +268,10 @@ svg text { font-size: 0.857rem; } + &.is-large text { + font-size: 1.25rem; + } + &.link text { fill: $link; font-size: 0.857rem; @@ -310,6 +325,12 @@ svg text { fill: $black !important; } +.g-arrow.is-light path { + opacity: 1; + stroke: $grey-light !important; + fill: $grey-light !important; +} + .legendOrdinal, .legendLine { .label { diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte index 1de5dbc4..62dcab52 100644 --- a/src/lib/components/Navbar.svelte +++ b/src/lib/components/Navbar.svelte @@ -56,6 +56,7 @@ label: "Cash Flow", href: "/cash_flow", children: [ + { label: "Income Statement", href: "/income_statement", financialYearPicker: true }, { label: "Monthly", href: "/monthly", dateRangeSelector: true }, { label: "Yearly", diff --git a/src/lib/components/PostingCard.svelte b/src/lib/components/PostingCard.svelte index e50b0c19..a41282f5 100644 --- a/src/lib/components/PostingCard.svelte +++ b/src/lib/components/PostingCard.svelte @@ -28,7 +28,7 @@ {posting.date.format("DD MMM YYYY")}
-
+
{#if icon} diff --git a/src/lib/components/RecurringCard.svelte b/src/lib/components/RecurringCard.svelte index a7b56038..16c3c5ca 100644 --- a/src/lib/components/RecurringCard.svelte +++ b/src/lib/components/RecurringCard.svelte @@ -60,7 +60,7 @@ {schedule.scheduled.format("DD MMM YYYY")}
-
+
diff --git a/src/lib/components/TransactionCard.svelte b/src/lib/components/TransactionCard.svelte index 8bc6a0e3..51b15560 100644 --- a/src/lib/components/TransactionCard.svelte +++ b/src/lib/components/TransactionCard.svelte @@ -31,7 +31,7 @@ {posting.date.format("DD MMM YYYY")} -
+
{#each t.postings as posting}
diff --git a/src/lib/income_statement.ts b/src/lib/income_statement.ts new file mode 100644 index 00000000..4ea1935b --- /dev/null +++ b/src/lib/income_statement.ts @@ -0,0 +1,323 @@ +import * as d3 from "d3"; +import { formatCurrency, formatCurrencyCrude, tooltip, type IncomeStatement, rem } from "./utils"; +import COLORS from "./colors"; +import _ from "lodash"; +import { iconGlyph, iconify } from "./icon"; +import { pathArrows } from "d3-path-arrows"; + +export function renderIncomeStatement(element: Element) { + const BARS = 4; + const BAR_HEIGHT = 100; + + const svg = d3.select(element), + margin = { top: rem(20), right: rem(20), bottom: rem(10), left: rem(100) }, + width = Math.max(element.parentElement.clientWidth, 600) - margin.left - margin.right, + g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + const height = BAR_HEIGHT * BARS; + svg + .attr("height", height + margin.top + margin.bottom) + .attr("width", width + margin.left + margin.right); + + const sum = (object: Record) => Object.values(object).reduce((a, b) => a + b, 0); + const y = d3.scaleBand().range([height, 0]).paddingInner(0.4).paddingOuter(0.6); + const x = d3.scaleLinear().range([0, width]); + + const xAxis = g + .append("g") + .attr("class", "axis y") + .attr("transform", "translate(0," + height + ")"); + + const yAxis = g.append("g").attr("class", "axis y dark is-large"); + + const garrows = g.append("g"); + const gbars = g.append("g"); + const glines = g.append("g"); + const gmarks = g.append("g"); + const gamounts = g.append("g"); + const gicons = g.append("g"); + + interface Bar { + label: string; + value: number; + start: number; + end: number; + color: string; + breakdown: Record; + multiplier: number; + } + + const arrows = pathArrows() + .arrowLength(10) + .gapLength(100) + .arrowHeadSize(3) + .path((d: Bar) => { + const path = d3.path(); + + const startY = y(d.label) + y.bandwidth() + 4; + const startX = x(d.start); + const endX = x(d.end); + + path.moveTo(startX, startY); + path.lineTo(endX, startY); + return path.toString(); + }); + + let firstRender = true; + return function (statement: IncomeStatement) { + const incomeStart = statement.startingBalance; + const income = sum(statement.income) * -1; + const taxStart = incomeStart + income; + const tax = sum(statement.tax) * -1; + const interestStart = taxStart + tax; + const interest = sum(statement.interest) * -1; + const pnlStart = interestStart + interest; + const pnl = sum(statement.pnl); + const equityStart = pnlStart + pnl; + const equity = sum(statement.equity) * -1; + const liabilitiesStart = equityStart + equity; + const liabilities = sum(statement.liabilities) * -1; + const expensesStart = liabilitiesStart + liabilities; + const expenses = sum(statement.expenses) * -1; + const expensesEnd = expensesStart + expenses; + const t = svg.transition().duration(firstRender ? 0 : 750); + firstRender = false; + + const bars: Bar[] = [ + { + label: "Income", + start: incomeStart, + end: incomeStart + income, + color: COLORS.income, + value: income, + breakdown: statement.income, + multiplier: -1 + }, + { + label: "Tax", + start: taxStart, + end: taxStart + tax, + color: COLORS.expenses, + value: tax, + breakdown: statement.tax, + multiplier: -1 + }, + { + label: "Interest", + start: interestStart, + end: interestStart + interest, + color: COLORS.income, + value: interest, + breakdown: statement.interest, + multiplier: -1 + }, + { + label: "Gain / Loss", + start: pnlStart, + end: pnlStart + pnl, + color: pnl > 0 ? COLORS.gain : COLORS.loss, + value: pnl, + breakdown: statement.pnl, + multiplier: 1 + }, + { + label: "Equity", + start: equityStart, + end: equityStart + equity, + color: COLORS.equity, + value: equity, + breakdown: statement.equity, + multiplier: -1 + }, + { + label: "Liabilities", + start: liabilitiesStart, + end: liabilitiesStart + liabilities, + color: COLORS.liabilities, + value: liabilities, + breakdown: statement.liabilities, + multiplier: -1 + }, + { + label: "Expenses", + start: expensesStart, + end: expensesStart + expenses, + color: COLORS.expenses, + value: expenses, + breakdown: statement.expenses, + multiplier: -1 + } + ]; + + interface Line { + label: string; + value: number; + anchor: string; + down?: boolean; + icon?: string; + } + + const lines: Line[] = [ + { label: "Income", value: incomeStart, anchor: "start", icon: "fa6-solid:caret-down" }, + { label: "Tax", value: taxStart, anchor: "end" }, + { label: "Interest", value: interestStart, anchor: "end" }, + { label: "Gain / Loss", value: pnlStart, anchor: "end" }, + { label: "Equity", value: equityStart, anchor: "end" }, + { label: "Liabilities", value: liabilitiesStart, anchor: "end" }, + { label: "Expenses", value: expensesStart, anchor: "end" }, + { + label: "Expenses", + value: expensesEnd, + down: true, + anchor: "start", + icon: "fa6-solid:caret-up" + } + ]; + + y.domain(bars.map((d) => d.label).reverse()); + x.domain( + d3.extent([ + incomeStart, + interestStart, + taxStart, + pnlStart, + equityStart, + liabilitiesStart, + expensesStart, + expensesEnd + ]) + ); + + xAxis.transition(t).call(d3.axisTop(x).tickSize(height).tickFormat(formatCurrencyCrude)); + yAxis.transition(t).call(d3.axisLeft(y).tickSize(-width)); + + garrows.selectAll("g").remove(); + t.on("end", () => { + garrows.selectAll("g").data(bars).join("g").attr("class", "g-arrow is-light").call(arrows); + }); + + gbars + .selectAll("rect") + .data(bars) + .join("rect") + .attr("stroke", (d) => d.color) + .attr("fill", (d) => d.color) + .attr("fill-opacity", 0.5) + .attr("data-tippy-content", (d) => { + return tooltip( + _.map(d.breakdown, (value, label) => [ + iconify(label), + [formatCurrency(value * d.multiplier), "has-text-right has-text-weight-bold"] + ]), + { header: d.label, total: formatCurrency(d.value) } + ); + }) + .transition(t) + .attr("x", function (d) { + if (d.value < 0) { + return x(d.end); + } + return x(d.start); + }) + .attr("y", function (d) { + return y(d.label) + (y.bandwidth() - Math.min(y.bandwidth(), BAR_HEIGHT)) / 2; + }) + .attr("width", function (d) { + if (d.value < 0) { + return x(d.start) - x(d.end); + } + return x(d.end) - x(d.start); + }) + .attr("height", y.bandwidth()); + + glines + .selectAll("line") + .data(lines) + .join("line") + .attr("class", "svg-grey") + .attr("stroke-width", 1) + .attr("stroke-dasharray", "2,2") + .attr("stroke-opacity", 0.5) + .transition(t) + .attr("x1", function (d) { + return x(d.value); + }) + .attr("x2", function (d) { + return x(d.value); + }) + .attr("y1", function (d) { + if (d.down) { + return y(d.label); + } else { + return y(d.label) - y.step() * y.paddingInner(); + } + }) + .attr("y2", function (d) { + if (d.down) { + return y(d.label) + y.bandwidth() + y.step() * y.paddingInner(); + } else { + return y(d.label) + y.bandwidth(); + } + }); + + gmarks + .selectAll("text") + .data(lines) + .join("text") + .attr("text-anchor", (d) => d.anchor) + .attr("font-size", "0.7rem") + .attr("class", "svg-text-grey") + .attr("dy", (d) => (d.down ? "-0.5rem" : "1rem")) + .attr("dx", (d) => (d.anchor === "start" ? "0.3rem" : "-0.3rem")) + .transition(t) + .attr("x", function (d) { + return x(d.value); + }) + .attr("y", function (d) { + if (d.down) { + return y(d.label) + y.bandwidth() + y.step() * y.paddingInner(); + } else { + return y(d.label) - y.step() * y.paddingInner(); + } + }) + .text((d) => formatCurrency(d.value)); + + gamounts + .selectAll("text") + .data(bars) + .join("text") + .attr("dy", "0.3rem") + .attr("font-size", "0.8rem") + .attr("text-anchor", "middle") + .attr("class", "svg-text-black-bis") + .transition(t) + .attr("x", function (d) { + return (x(d.start) + x(d.end)) / 2; + }) + .attr("y", function (d) { + return y(d.label) + y.bandwidth() / 2; + }) + .text((d) => formatCurrency(d.value)); + + gicons + .selectAll("text") + .data([_.first(lines), _.last(lines)]) + .join("text") + .attr("text-anchor", "middle") + .attr("font-size", "1.2rem") + .attr("class", "svg-text-grey") + .attr("dy", (d) => (d.down ? "0.8rem" : "0.2rem")) + .transition(t) + .attr("x", function (d) { + return x(d.value); + }) + .attr("y", function (d) { + if (d.down) { + return y(d.label) + y.bandwidth() + y.step() * y.paddingInner(); + } else { + return y(d.label) - y.step() * y.paddingInner(); + } + }) + .text((d) => iconGlyph(d.icon)); + }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2674c4b0..2717e8ec 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -43,6 +43,19 @@ export interface Posting { balance: number; } +export interface IncomeStatement { + startingBalance: number; + endingBalance: number; + date: dayjs.Dayjs; + income: Record; + interest: Record; + equity: Record; + pnl: Record; + liabilities: Record; + tax: Record; + expenses: Record; +} + export interface CashFlow { date: dayjs.Dayjs; income: number; @@ -545,6 +558,9 @@ export function ajax(route: "/api/budget"): Promise<{ }>; export function ajax(route: "/api/cash_flow"): Promise<{ cash_flows: CashFlow[] }>; +export function ajax( + route: "/api/income_statement" +): Promise<{ yearly: Record }>; export function ajax( route: "/api/recurring" @@ -800,12 +816,7 @@ export function forEachFinancialYear( end: dayjs.Dayjs, cb?: (current: dayjs.Dayjs) => any ) { - let current = start; - if (current.month() < 3) { - current = current.year(current.year() - 1); - } - current = current.month(3).date(1); - + let current = begingingOfFinancialYear(start); const years: dayjs.Dayjs[] = []; while (current.isSameOrBefore(end, "month")) { if (cb) { @@ -817,6 +828,17 @@ export function forEachFinancialYear( return years; } +function begingingOfFinancialYear(date: dayjs.Dayjs) { + date = date.startOf("month"); + if (date.month() + 1 < USER_CONFIG.financial_year_starting_month) { + return date + .add(-1, "year") + .add(USER_CONFIG.financial_year_starting_month - date.month() + 1, "month"); + } else { + return date.add(-(date.month() + 1 - USER_CONFIG.financial_year_starting_month), "month"); + } +} + export function firstName(account: string) { return _.first(account.split(":")); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 77ea9006..ce8b52fa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -28,6 +28,16 @@ delay: 0, allowHTML: true, followCursor: true, + popperOptions: { + modifiers: [ + { + name: "flip", + options: { + fallbackPlacements: ["auto"] + } + } + ] + }, plugins: [followCursor] }); } diff --git a/src/routes/assets/balance/+page.svelte b/src/routes/assets/balance/+page.svelte index 566493ee..4424382a 100644 --- a/src/routes/assets/balance/+page.svelte +++ b/src/routes/assets/balance/+page.svelte @@ -35,7 +35,7 @@
- +
diff --git a/src/routes/cash_flow/income_statement/+page.svelte b/src/routes/cash_flow/income_statement/+page.svelte new file mode 100644 index 00000000..e36ea63d --- /dev/null +++ b/src/routes/cash_flow/income_statement/+page.svelte @@ -0,0 +1,281 @@ + + +
+
+
+ {#if incomeStatement} +
+
+
+
+ {$year} +
+
+ Start + {formatCurrency(incomeStatement.startingBalance)} +
+
+ End + {formatCurrency(incomeStatement.endingBalance)} +
+ +
+ change + {formatCurrency(diff)} + {formatPercentage(diffPercent, 2)} +
+
+
+
+ {/if} +
+
+ Oops! You have not made any transactions for the selected year. + + +
+
+
+
+
+ +
+
+
+
+
+
Account
+ + + + {#each years as y} + + {/each} + + + + {#each accountGroups as group} + + + {#each years as y} + + {/each} + + {#each group.accounts as account} + + + {#each years as y} + + {/each} + + {/each} + + {/each} + + + + {#each years as y} + {#if yearly[y]} + {@const diff = yearly[y].endingBalance - yearly[y].startingBalance} + + {:else} + + {/if} + {/each} + + + + {#each years as y} + + {/each} + + + + {#each years as y} + + {/each} + + +
Account{y}
{group.label} + {#if yearly[y]?.[group.key]} + {formatUnlessZero(sum(yearly[y][group.key]) * group.multiplier)} + {/if} +
{iconify(restName(account), { group: firstName(account) })} + {#if yearly[y]?.[group.key]?.[account]} + {formatUnlessZero(yearly[y][group.key][account] * group.multiplier)} + {/if} +
 
Change +
{formatCurrency(diff)}
+
{formatPercentage(diff / yearly[y].startingBalance)}
+
End Balance + {#if yearly[y]} + {formatCurrency(yearly[y].endingBalance)} + {/if} +
Start Balance + {#if yearly[y]} + {formatCurrency(yearly[y].startingBalance)} + {/if} +
+
+
+
+
+ diff --git a/src/routes/liabilities/balance/+page.svelte b/src/routes/liabilities/balance/+page.svelte index 438a9a41..2396cc80 100644 --- a/src/routes/liabilities/balance/+page.svelte +++ b/src/routes/liabilities/balance/+page.svelte @@ -52,7 +52,7 @@
- +
diff --git a/tests/fixture/eur-hledger/income_statement.json b/tests/fixture/eur-hledger/income_statement.json new file mode 100644 index 00000000..4f746226 --- /dev/null +++ b/tests/fixture/eur-hledger/income_statement.json @@ -0,0 +1,20 @@ +{ + "yearly": { + "2021 - 22": { + "startingBalance": 0, + "endingBalance": 11020, + "date": "2021-04-01T00:00:00Z", + "income": { + "Income:Salary:Acme": -11000 + }, + "interest": {}, + "equity": {}, + "pnl": { + "Assets:Equity:AAPL": 20 + }, + "liabilities": {}, + "tax": {}, + "expenses": {} + } + } +} \ No newline at end of file diff --git a/tests/fixture/eur/income_statement.json b/tests/fixture/eur/income_statement.json new file mode 100644 index 00000000..4f746226 --- /dev/null +++ b/tests/fixture/eur/income_statement.json @@ -0,0 +1,20 @@ +{ + "yearly": { + "2021 - 22": { + "startingBalance": 0, + "endingBalance": 11020, + "date": "2021-04-01T00:00:00Z", + "income": { + "Income:Salary:Acme": -11000 + }, + "interest": {}, + "equity": {}, + "pnl": { + "Assets:Equity:AAPL": 20 + }, + "liabilities": {}, + "tax": {}, + "expenses": {} + } + } +} \ No newline at end of file diff --git a/tests/fixture/inr-beancount/income_statement.json b/tests/fixture/inr-beancount/income_statement.json new file mode 100644 index 00000000..1ed105ce --- /dev/null +++ b/tests/fixture/inr-beancount/income_statement.json @@ -0,0 +1,26 @@ +{ + "yearly": { + "2021 - 22": { + "startingBalance": 0, + "endingBalance": 93221.56129152, + "date": "2021-04-01T00:00:00Z", + "income": { + "Income:Salary:Acme": -120000 + }, + "interest": { + "Income:Interest:Checking": -1000 + }, + "equity": {}, + "pnl": { + "Assets:Equity:AAPL": 27.3, + "Assets:Equity:ABNB": -8024.13870848, + "Assets:Equity:NIFTY": 218.4 + }, + "liabilities": {}, + "tax": {}, + "expenses": { + "Expenses:Rent": 20000 + } + } + } +} \ No newline at end of file diff --git a/tests/fixture/inr-hledger/income_statement.json b/tests/fixture/inr-hledger/income_statement.json new file mode 100644 index 00000000..ccebf810 --- /dev/null +++ b/tests/fixture/inr-hledger/income_statement.json @@ -0,0 +1,26 @@ +{ + "yearly": { + "2021 - 22": { + "startingBalance": 0, + "endingBalance": 93221.1429928704, + "date": "2021-04-01T00:00:00Z", + "income": { + "Income:Salary:Acme": -120000 + }, + "interest": { + "Income:Interest:Checking": -1000 + }, + "equity": {}, + "pnl": { + "Assets:Equity:AAPL": 27.3, + "Assets:Equity:ABNB": -8024.5570071296, + "Assets:Equity:NIFTY": 218.4 + }, + "liabilities": {}, + "tax": {}, + "expenses": { + "Expenses:Rent": 20000 + } + } + } +} \ No newline at end of file diff --git a/tests/fixture/inr/income_statement.json b/tests/fixture/inr/income_statement.json new file mode 100644 index 00000000..ccebf810 --- /dev/null +++ b/tests/fixture/inr/income_statement.json @@ -0,0 +1,26 @@ +{ + "yearly": { + "2021 - 22": { + "startingBalance": 0, + "endingBalance": 93221.1429928704, + "date": "2021-04-01T00:00:00Z", + "income": { + "Income:Salary:Acme": -120000 + }, + "interest": { + "Income:Interest:Checking": -1000 + }, + "equity": {}, + "pnl": { + "Assets:Equity:AAPL": 27.3, + "Assets:Equity:ABNB": -8024.5570071296, + "Assets:Equity:NIFTY": 218.4 + }, + "liabilities": {}, + "tax": {}, + "expenses": { + "Expenses:Rent": 20000 + } + } + } +} \ No newline at end of file diff --git a/tests/regression.test.ts b/tests/regression.test.ts index 3f1917fb..d452fd00 100644 --- a/tests/regression.test.ts +++ b/tests/regression.test.ts @@ -42,6 +42,7 @@ async function verifyApi(dir: string) { await recordAndVerify(dir, "/api/dashboard", "dashboard"); await recordAndVerify(dir, "/api/cash_flow", "cash_flow"); + await recordAndVerify(dir, "/api/income_statement", "income_statement"); await recordAndVerify(dir, "/api/expense", "expense"); await recordAndVerify(dir, "/api/recurring", "recurring"); await recordAndVerify(dir, "/api/budget", "budget");
Account