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/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 @@
-
+
Account |
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}
+ {y} |
+ {/each}
+
+
+
+ {#each accountGroups as group}
+
+ {group.label} |
+ {#each years as y}
+
+ {#if yearly[y]?.[group.key]}
+ {formatUnlessZero(sum(yearly[y][group.key]) * group.multiplier)}
+ {/if}
+ |
+ {/each}
+
+ {#each group.accounts as account}
+
+ {iconify(restName(account), { group: firstName(account) })} |
+ {#each years as y}
+
+ {#if yearly[y]?.[group.key]?.[account]}
+ {formatUnlessZero(yearly[y][group.key][account] * group.multiplier)}
+ {/if}
+ |
+ {/each}
+
+ {/each}
+ |
+ {/each}
+
+
+ Change |
+ {#each years as y}
+ {#if yearly[y]}
+ {@const diff = yearly[y].endingBalance - yearly[y].startingBalance}
+
+ {formatCurrency(diff)}
+ {formatPercentage(diff / yearly[y].startingBalance)}
+ |
+ {:else}
+ |
+ {/if}
+ {/each}
+
+
+ End Balance |
+ {#each years as y}
+
+ {#if yearly[y]}
+ {formatCurrency(yearly[y].endingBalance)}
+ {/if}
+ |
+ {/each}
+
+
+ Start Balance |
+ {#each years as y}
+
+ {#if yearly[y]}
+ {formatCurrency(yearly[y].startingBalance)}
+ {/if}
+ |
+ {/each}
+
+
+
+
+
+
+
+
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 @@
-
+
Account |
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");