[docs] add docs for goals

This commit is contained in:
Anantha Kumaran 2023-11-05 21:46:55 +05:30
parent 7f39975270
commit e473f49e04
24 changed files with 228 additions and 110 deletions

View File

@ -15,6 +15,17 @@ configuration file named `paisa/paisa.yaml` inside User Documents folder. The
default configuration is tuned for Indians, users from other countries
would have to change the `default_currency` and `locale`.
### Accounts
In many places, paisa expects you to specify a list of accounts. You
can type the full account name like `#!ledger
Account:Equity:APPL`. Paisa also supports wildcard `*`, you can use
`#!ledger Account:Equity:*` to represent all accounts under
Equity. It's also possible to use negation. `#!ledger !Expenses:Tax`
will match all accounts except Tax. If you use negation, then all the
accounts should be negation. Don't mix negation with others, if done
the behavior will be undefined.
```yaml
# Path to your journal file. It can be absolute or relative to the
# configuration file. The main journal file can refer other files using

View File

@ -0,0 +1,23 @@
# Goals
Goal helps you track your financial objective and progress. You can
create a goal for any financial objective you have, such as saving for
a vacation, or building an emergency fund or planning for retirement.
Paisa currently supports two types of goals:
1. [Retirement](./retirement.md)
2. [Savings](./savings.md)
More goal types will be added in the future. Feel free to create a
[discussion](https://github.com/ananthakumaran/paisa/discussions) if you have any suggestions.
### Create Goal
To add a new goal, go to the `Configuration` page and expand the goals
section. You would see a list of goal types. Expand the type you want
to add and click on the :fontawesome-solid-circle-plus: icon to add a
new goal. The configuration details are available in the respective
goal type pages.

View File

@ -0,0 +1,56 @@
# Retirement
Paisa will help you plan your retirement and track your progress. The
first part is figuring out what should be your retirement corpus. This
will be your target. Instead of specifying the amount explicitly, you
can specify your expected yearly expenses and the safe withdrawal
rate.
```yaml
goals:
retirement:
- name: Retirement
icon: mdi:palm-tree
swr: 3.3
yearly_expenses: 1100000
```
If you use paisa to track expenses, instead of specifying the
`yearly_expenses`, you can specify the list of accounts. Paisa will
take the average of the last 3 year expenses
```yaml
goals:
retirement:
- name: Retirement
icon: mdi:palm-tree
swr: 2
expenses:
- Expenses:Entertainment
- Expenses:Gift
- Expenses:Insurance
- Expenses:Misc
- Expenses:Shopping
- Expenses:Utilities
```
Now that the target is specified, you need to specify the list of
accounts where you keep your retirement savings.
```yaml
goals:
retirement:
- name: Retirement
icon: mdi:palm-tree
swr: 2
expenses:
- Expenses:Entertainment
- Expenses:Gift
- Expenses:Insurance
- Expenses:Misc
- Expenses:Shopping
- Expenses:Utilities
savings:
- Assets:Equity:*
- Assets:Debt:*
```

View File

@ -0,0 +1,17 @@
# Savings
Savings represents a general financial objective that you want to
achieve, like buying a car, or a house, or a vacation.
```yaml
goals:
retirement:
- name: House
target: 5000000
icon: fluent-emoji-high-contrast:house-with-garden
accounts:
- Assets:Equity:*
- Assets:Debt:*
```
Specify the target amount, and the accounts that you keep the money in.

View File

@ -1,47 +0,0 @@
# Retirement
Paisa will help you plan your retirement and track your progress. The
first part is figuring out what should be your retirement corpus. This
will be your target. Instead of specifying the amount explicitly, you
can specify your expected yearly expenses and the safe withdrawal
rate.
```yaml
retirement:
swr: 3.3
yearly_expenses: 1100000
```
If you use paisa to track expenses, instead of specifying the
`yearly_expenses`, you can specify the list of accounts. Paisa will
take the average of the last 3 year expenses
```yaml
retirement:
swr: 2
expenses:
- Expenses:Entertainment
- Expenses:Gift
- Expenses:Insurance
- Expenses:Misc
- Expenses:Shopping
- Expenses:Utilities
```
Now that the target is specified, you need to specify the list of
accounts where you keep your retirement savings.
```yaml
retirement:
swr: 2
expenses:
- Expenses:Entertainment
- Expenses:Gift
- Expenses:Insurance
- Expenses:Misc
- Expenses:Shopping
- Expenses:Utilities
savings:
- Assets:Equity:*
- Assets:Debt:*
```

View File

@ -1,9 +1,8 @@
@font-face {
font-family: "arcticons";
font-style: normal;
font-weight: 900;
src: url("../fonts/arcticons.woff2") format("woff2");
unicode-range: U+F0001-F1D3D;
font-display: swap;
font-display: block;
}

View File

@ -5,5 +5,5 @@
font-weight: 900;
src: url("../fonts/fa6-brands.woff2") format("woff2");
unicode-range: U+F2356-F2544;
font-display: swap;
font-display: block;
}

View File

@ -5,5 +5,5 @@
font-weight: 900;
src: url("../fonts/fa6-regular.woff2") format("woff2");
unicode-range: U+F22B2-F2355;
font-display: swap;
font-display: block;
}

View File

@ -5,5 +5,5 @@
font-weight: 900;
src: url("../fonts/fa6-solid.woff2") format("woff2");
unicode-range: U+F1D3E-F22B1;
font-display: swap;
font-display: block;
}

View File

@ -5,5 +5,5 @@
font-weight: 900;
src: url("../fonts/fluent-emoji-high-contrast.woff2") format("woff2");
unicode-range: U+F5B53-F615C;
font-display: swap;
font-display: block;
}

View File

@ -5,5 +5,5 @@
font-weight: 900;
src: url("../fonts/mdi.woff2") format("woff2");
unicode-range: U+F2545-F5B52;
font-display: swap;
font-display: block;
}

View File

@ -58,7 +58,7 @@ async function createFont(font) {
font-weight: 900;
src: url("../fonts/${font}.woff2") format("woff2");
unicode-range: U+${min.toString(16).toUpperCase()}-${max.toString(16).toUpperCase()};
font-display: swap;
font-display: block;
}
`;

View File

@ -83,7 +83,6 @@
"minimum": 1,
"maximum": 10
},
"yearly_expenses": {
"type": "integer",
"description": "By default, average of last 3 year expenses will be used to calculate your yearly expenses. This can be overriden by setting this configuration to positive value"
@ -108,7 +107,7 @@
}
},
"ui:header": "name",
"required": ["name", "swr", "savings"],
"required": ["name", "icon", "swr", "savings"],
"additionalProperties": false
}
},
@ -150,7 +149,7 @@
}
},
"ui:header": "name",
"required": ["name", "target", "accounts"],
"required": ["name", "icon", "target", "accounts"],
"additionalProperties": false
}
}

View File

@ -69,18 +69,27 @@ journal_path: '%s'
db_path: '%s'
ledger_cli: ledger
default_currency: INR
retirement:
swr: 3
goals:
retirement:
- name: Early Retirement
icon: mdi:palm-tree
swr: 3
savings:
- Assets:Debt:*
- Assets:Equity:*
expenses:
- Expenses:Rent
- Expenses:Utilities
- Expenses:Shopping
- Expenses:Restaurants
- Expenses:Food
- Expenses:Interest:*
savings:
- Assets:Debt:*
- Assets:Equity:*
expenses:
- Expenses:Rent
- Expenses:Utilities
- Expenses:Shopping
- Expenses:Restaurants
- Expenses:Food
- Expenses:Interest:*
- name: Millionaire
icon: mdi:car-sports
target: 80000000
accounts:
- '!Assets:Checking'
allocation_targets:
- name: Debt
target: 40

View File

@ -53,5 +53,14 @@ func getRetirementDetail(db *gorm.DB, conf config.RetirementGoal) gin.H {
yearlyExpenses = calculateAverageExpense(db, conf)
}
return gin.H{"savings_timeline": accounting.RunningBalance(db, savings), "savings_total": savingsTotal, "swr": conf.SWR, "yearly_expense": yearlyExpenses, "xirr": service.XIRR(db, savingsWithCapitalGains)}
return gin.H{
"type": "retirement",
"name": conf.Name,
"icon": conf.Icon,
"savingsTimeline": accounting.RunningBalance(db, savings),
"savingsTotal": savingsTotal,
"swr": conf.SWR,
"yearlyExpense": yearlyExpenses,
"xirr": service.XIRR(db, savingsWithCapitalGains),
}
}

View File

@ -33,10 +33,13 @@ func getSavingsDetail(db *gorm.DB, conf config.SavingsGoal) gin.H {
savingsWithCapitalGains = service.PopulateMarketPrice(db, savingsWithCapitalGains)
return gin.H{
"savings_timeline": accounting.RunningBalance(db, savings),
"savings_total": savingsTotal,
"target": decimal.NewFromFloat(conf.Target),
"xirr": service.XIRR(db, savingsWithCapitalGains),
"postings": savingsWithCapitalGains,
"type": "savings",
"name": conf.Name,
"icon": conf.Icon,
"savingsTimeline": accounting.RunningBalance(db, savings),
"savingsTotal": savingsTotal,
"target": decimal.NewFromFloat(conf.Target),
"xirr": service.XIRR(db, savingsWithCapitalGains),
"postings": savingsWithCapitalGains,
}
}

View File

@ -17,9 +17,12 @@ nav:
- reference/budget.md
- reference/bulk-edit.md
- reference/import.md
- reference/retirement.md
- reference/recurring.md
- reference/config.md
- 'Goals':
- reference/goals/index.md
- reference/goals/retirement.md
- reference/goals/savings.md
- reference/ledger-cli.md
- reference/analysis.md
- 'Tax':

View File

@ -119,7 +119,7 @@
href: "/more",
children: [
{ label: "Configuration", href: "/config", tag: "alpha" },
{ label: "Goals", href: "/goals" },
{ label: "Goals", href: "/goals", help: "goals" },
{ label: "Doctor", href: "/doctor" },
{ label: "Logs", href: "/logs" }
]

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { formatCurrency, type Posting } from "$lib/utils";
import _ from "lodash";
export let postings: Posting[];
export let groupFormat: string;
@ -12,6 +13,7 @@
let groupedPostings: GroupedPosting[] = [];
$: groupedPostings = group(postings);
$: isGrouped = _.some(groupedPostings, (groupedPosting) => groupedPosting.postings.length > 1);
function group(ps: Posting[]) {
let groupedPostings: GroupedPosting[] = [];
@ -45,11 +47,13 @@
<div>
{#each groupedPostings as groupedPosting}
<div class="mb-3">
<div class="flex justify-between -mb-1 has-text-weight-bold has-text-grey-light">
<div>{groupedPosting.key}</div>
<div>{formatCurrency(groupedPosting.total)}</div>
</div>
<div class={isGrouped && "mb-3"}>
{#if isGrouped}
<div class="flex justify-between -mb-1 has-text-weight-bold has-text-grey-light">
<div>{groupedPosting.key}</div>
<div>{formatCurrency(groupedPosting.total)}</div>
</div>
{/if}
<slot groupedPostings={groupedPosting.postings} />
</div>
{/each}

View File

@ -330,19 +330,25 @@ export interface AccountBudget {
}
export interface RetirementGoalProgress {
savings_total: number;
savings_timeline: Point[];
savingsTotal: number;
savingsTimeline: Point[];
swr: number;
yearly_expense: number;
yearlyExpense: number;
xirr: number;
name: string;
type: string;
icon: string;
}
export interface SavingsGoalProgress {
savings_total: number;
savings_timeline: Point[];
savingsTotal: number;
savingsTimeline: Point[];
target: number;
xirr: number;
postings: Posting[];
name: string;
type: string;
icon: string;
}
export interface LedgerFile {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { ajax, formatCurrency, formatFloat, type Networth } from "$lib/utils";
import { ajax, formatCurrency, formatFloat, isMobile, type Networth } from "$lib/utils";
import COLORS from "$lib/colors";
import { renderNetworth } from "$lib/networth";
import _ from "lodash";
@ -50,8 +50,8 @@
</script>
<section class="section tab-networth">
<div class="container">
<nav class="level">
<div class="container is-fluid">
<nav class="level {isMobile() && 'grid-2'}">
<LevelItem title="Net worth" color={COLORS.primary} value={formatCurrency(networth)} />
<LevelItem
title="Net Investment"

View File

@ -2,16 +2,20 @@
import COLORS from "$lib/colors";
import LevelItem from "$lib/components/LevelItem.svelte";
import Progress from "$lib/components/Progress.svelte";
import ZeroState from "$lib/components/ZeroState.svelte";
import { iconGlyph } from "$lib/icon";
import { ajax, formatCurrency, type GoalSummary } from "$lib/utils";
import { ajax, formatCurrency, helpUrl, type GoalSummary } from "$lib/utils";
import _ from "lodash";
import { onMount } from "svelte";
let isEmpty = false;
let goals: GoalSummary[] = [];
onMount(async () => {
({ goals } = await ajax("/api/goals"));
console.log(goals);
if (_.isEmpty(goals)) {
isEmpty = true;
}
});
function percentComplete(goal: GoalSummary) {
@ -22,8 +26,15 @@
<section class="section">
<div class="container is-fluid">
<div class="columns flex-wrap">
<div class="column is-12">
<ZeroState item={!isEmpty}>
<strong>Oops!</strong> You haven't configured any goals yet. Checkout the
<a href={helpUrl("goals")}>docs</a> page to get started.
</ZeroState>
</div>
{#each goals as goal}
<div class="column is-one-third-widescreen is-half-desktop">
<div class="column is-6 is-one-third-widescreen">
<div class="box p-3">
<div class="flex justify-between mb-4">
<a

View File

@ -1,17 +1,20 @@
<script lang="ts">
import COLORS from "$lib/colors";
import Progress from "$lib/components/Progress.svelte";
import { ajax, formatCurrency, formatFloat, type Point } from "$lib/utils";
import { ajax, formatCurrency, formatFloat, isMobile, type Point } from "$lib/utils";
import { onMount, tick, onDestroy } from "svelte";
import ARIMAPromise from "arima/async";
import { forecast, renderProgress, findBreakPoints } from "$lib/goals";
import LevelItem from "$lib/components/LevelItem.svelte";
import type { PageData } from "./$types";
import { iconGlyph } from "$lib/icon";
export let data: PageData;
let svg: Element;
let savingsTotal = 0,
icon = "",
name = "",
targetSavings = 0,
swr = 0,
xirr = 0,
@ -28,13 +31,11 @@
});
onMount(async () => {
({
savings_total: savingsTotal,
savings_timeline: savingsTimeline,
yearly_expense: yearlyExpense,
swr,
xirr
} = await ajax("/api/goals/retirement/:name", null, data));
({ savingsTotal, savingsTimeline, yearlyExpense, swr, xirr, icon, name } = await ajax(
"/api/goals/retirement/:name",
null,
data
));
targetSavings = yearlyExpense * (100 / swr);
if (yearlyExpense > 0) {
@ -59,7 +60,8 @@
<section class="section">
<div class="container is-fluid">
<nav class="level">
<nav class="level custom-icon {isMobile() && 'grid-2'}">
<LevelItem title={name} value={iconGlyph(icon)} />
<LevelItem
title="Current Savings"
value={formatCurrency(savingsTotal)}
@ -100,9 +102,9 @@
</div>
</div>
<div class="columns">
<div class="column is-12 has-text-centered">
<div class="column is-12 has-text-centered has-text-grey">
<div>
<p class="heading">Retirement Progress</p>
<p class="is-size-5 custom-icon">{iconGlyph(icon)} {name} progress</p>
</div>
</div>
</div>

View File

@ -1,7 +1,14 @@
<script lang="ts">
import COLORS from "$lib/colors";
import Progress from "$lib/components/Progress.svelte";
import { ajax, formatCurrency, formatFloat, type Point, type Posting } from "$lib/utils";
import {
ajax,
formatCurrency,
formatFloat,
isMobile,
type Point,
type Posting
} from "$lib/utils";
import { onMount, tick, onDestroy } from "svelte";
import ARIMAPromise from "arima/async";
import { forecast, renderProgress, findBreakPoints } from "$lib/goals";
@ -10,6 +17,7 @@
import type { PageData } from "./$types";
import PostingCard from "$lib/components/PostingCard.svelte";
import PostingGroup from "$lib/components/PostingGroup.svelte";
import { iconGlyph } from "$lib/icon";
export let data: PageData;
@ -17,6 +25,8 @@
let savingsTotal = 0,
targetSavings = 0,
xirr = 0,
name = "",
icon = "",
progressPercent = 0,
breakPoints: Point[] = [],
savingsTimeline: Point[] = [],
@ -29,10 +39,12 @@
onMount(async () => {
({
savings_total: savingsTotal,
savings_timeline: savingsTimeline,
savingsTotal,
savingsTimeline,
target: targetSavings,
postings,
icon,
name,
xirr
} = await ajax("/api/goals/savings/:name", null, data));
@ -56,7 +68,8 @@
<section class="section">
<div class="container is-fluid">
<nav class="level">
<nav class="level custom-icon {isMobile() && 'grid-2'}">
<LevelItem title={name} value={iconGlyph(icon)} />
<LevelItem
title="Current Savings"
value={formatCurrency(savingsTotal)}
@ -108,9 +121,9 @@
<svg height="500" bind:this={svg} />
</div>
</div>
<div class="column is-12 has-text-centered">
<div class="column is-12 has-text-centered has-text-grey">
<div>
<p class="heading">Savings Progress</p>
<p class="is-size-5 custom-icon">{iconGlyph(icon)} {name} progress</p>
</div>
</div>
</div>