[config] support price provider search option

This commit is contained in:
Anantha Kumaran 2023-09-18 17:34:15 +05:30
parent 1e54252df4
commit f27f6b2f06
35 changed files with 802 additions and 455 deletions

View File

@ -4,7 +4,7 @@ paisa
web/static/dist*
node_modules
example.ledger
personal.ledger
main.ledger
Dockerfile
fly.toml
.dockerignore

2
.gitignore vendored
View File

@ -3,7 +3,7 @@ paisa.yaml
./paisa
web/static
example.ledger
personal.ledger
main.ledger
.DS_Store
node_modules
/build

View File

@ -43,7 +43,7 @@ install:
fixture/main.transactions.json:
cd /tmp && paisa init
cp fixture/main.ledger /tmp/personal.ledger
cp fixture/main.ledger /tmp/main.ledger
cd /tmp && paisa update --journal && paisa serve -p 6500 &
sleep 1
curl http://localhost:6500/api/transaction | jq .transactions > fixture/main.transactions.json

View File

@ -2,9 +2,9 @@
[![Matrix](https://img.shields.io/matrix/paisa%3Amatrix.org?logo=matrix)](https://matrix.to/#/#paisa:matrix.org)
**Paisa** is a command line tool and Web UI to visualize personal
finance. It builds on top of the [ledger](https://www.ledger-cli.org/) double entry accounting
tool. Checkout [documentation](https://paisa.fyi) to get started.
**Paisa** is a Personal finance manager. It builds on
top of the [ledger](https://www.ledger-cli.org/) double entry accounting tool. Checkout
[documentation](https://paisa.fyi) to get started.
# Demo

View File

@ -1,88 +0,0 @@
package cmd
import (
"strings"
"github.com/ananthakumaran/paisa/internal/model"
"github.com/ananthakumaran/paisa/internal/model/mutualfund/scheme"
"github.com/ananthakumaran/paisa/internal/scraper/mutualfund"
"github.com/ananthakumaran/paisa/internal/utils"
"github.com/logrusorgru/aurora"
"github.com/manifoldco/promptui"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
var update bool
var mutualfundCmd = &cobra.Command{
Use: "mutualfund",
Short: "Search mutual fund",
Run: func(cmd *cobra.Command, args []string) {
db, err := utils.OpenDB()
if err != nil {
log.Fatal(err)
}
model.AutoMigrate(db)
count := scheme.Count(db)
if update || count == 0 {
schemes, err := mutualfund.GetSchemes()
if err != nil {
log.Fatal(err)
}
scheme.UpsertAll(db, schemes)
} else {
log.Info("Using cached results; pass '-u' to update the cache")
}
amc := promptAMC(db)
name := promptName(db, amc)
scheme := scheme.FindScheme(db, amc, name)
log.Info("Mutual Fund Scheme Code: ", aurora.Bold(scheme.Code))
},
}
func init() {
searchCmd.AddCommand(mutualfundCmd)
mutualfundCmd.Flags().BoolVarP(&update, "update", "u", false, "update the Mutual Fund Scheme list")
}
func promptAMC(db *gorm.DB) string {
amcs := scheme.GetAMCs(db)
return prompt("AMC", amcs)
}
func promptName(db *gorm.DB, amc string) string {
names := scheme.GetNAVNames(db, amc)
return prompt("Fund Name", names)
}
func prompt(label string, list []string) string {
searcher := func(input string, index int) bool {
item := list[index]
item = strings.Replace(strings.ToLower(item), " ", "", -1)
words := strings.Split(strings.ToLower(input), " ")
for _, word := range words {
if strings.TrimSpace(word) != "" && !strings.Contains(item, word) {
return false
}
}
return true
}
prompt := promptui.Select{
Label: label,
Items: list,
Size: 10,
Searcher: searcher,
StartInSearchMode: true,
}
_, item, err := prompt.Run()
if err != nil {
log.Fatal(err)
}
return item
}

View File

@ -1,88 +0,0 @@
package cmd
import (
"strings"
"github.com/ananthakumaran/paisa/internal/model"
"github.com/ananthakumaran/paisa/internal/model/nps/scheme"
"github.com/ananthakumaran/paisa/internal/scraper/nps"
"github.com/ananthakumaran/paisa/internal/utils"
"github.com/logrusorgru/aurora"
"github.com/manifoldco/promptui"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
var npsUpdate bool
var npsCmd = &cobra.Command{
Use: "nps",
Short: "Search nps fund",
Run: func(cmd *cobra.Command, args []string) {
db, err := utils.OpenDB()
if err != nil {
log.Fatal(err)
}
model.AutoMigrate(db)
count := scheme.Count(db)
if update || count == 0 {
schemes, err := nps.GetSchemes()
if err != nil {
log.Fatal(err)
}
scheme.UpsertAll(db, schemes)
} else {
log.Info("Using cached results; pass '-u' to update the cache")
}
pfm := promptPFM(db)
name := npsPromptName(db, pfm)
scheme := scheme.FindScheme(db, pfm, name)
log.Info("NPS Fund Scheme Code: ", aurora.Bold(scheme.SchemeID))
},
}
func init() {
searchCmd.AddCommand(npsCmd)
npsCmd.Flags().BoolVarP(&update, "update", "u", false, "update the NPS Fund Scheme list")
}
func promptPFM(db *gorm.DB) string {
pfms := scheme.GetPFMs(db)
return npsPrompt("Pension Fund Manager", pfms)
}
func npsPromptName(db *gorm.DB, pfm string) string {
names := scheme.GetSchemeNames(db, pfm)
return npsPrompt("Fund Name", names)
}
func npsPrompt(label string, list []string) string {
searcher := func(input string, index int) bool {
item := list[index]
item = strings.Replace(strings.ToLower(item), " ", "", -1)
words := strings.Split(strings.ToLower(input), " ")
for _, word := range words {
if strings.TrimSpace(word) != "" && !strings.Contains(item, word) {
return false
}
}
return true
}
prompt := promptui.Select{
Label: label,
Items: list,
Size: 10,
Searcher: searcher,
StartInSearchMode: true,
}
_, item, err := prompt.Run()
if err != nil {
log.Fatal(err)
}
return item
}

View File

@ -21,7 +21,7 @@ var configFile string
var rootCmd = &cobra.Command{
Use: "paisa",
Short: "A command line tool to manager personal finance",
Short: "Personal finance manager",
}
func Execute() {

View File

@ -1,14 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
)
var searchCmd = &cobra.Command{
Use: "search",
Short: "Search mutual fund",
}
func init() {
rootCmd.AddCommand(searchCmd)
}

View File

@ -13,6 +13,6 @@
"productName": "Paisa",
"productVersion": "0.5.0",
"copyright": "Copyright © 2022 - 2023 Anantha Kumaran",
"comments": "Personal Finance App"
"comments": "Personal finance manager"
}
}

View File

@ -1,5 +1,36 @@
# Installation
Paisa is available in two formats: a Desktop Application and a CLI (Command Line Interface).
## Desktop Application
=== "Mac"
* Download the prebuilt [binary](https://github.com/ananthakumaran/paisa/releases/latest) named `paisa-app-macos-amd64.dmg`
* Open the dmg file and drag the Paisa app into Application folder
* Since the app is not signed[^1], Mac will show a warning when
you try to open the app. You can check the
[support](https://support.apple.com/en-us/HT202491) page for more
details. If you don't get any option to open the app, go to the
Application folder, right click on the icon and select
open. Usually, this should present you with an option to open.
* Paisa will store all your journals, configuration files, and other
related files in a folder named `paisa` which will be located in your
`Documents` folder. When you open the app on your Mac for the
first time, a permission dialog will appear. Click Allow, then close and reopen the app.
=== "Windows"
* Download the prebuilt [binary](https://github.com/ananthakumaran/paisa/releases/latest) named `paisa-app-windows-amd64.exe`
* Since the app is not signed[^1], Windows will show multiple
warnings, You might have to click `Keep anyway`, `More info`, `Run
anyway` etc.
* Paisa will store all your journals, configuration files, and other
related files in a folder named `paisa` which will be located in your
`Documents` folder.
## CLI
=== "Linux"
* Download the prebuilt [binary](https://github.com/ananthakumaran/paisa/releases/latest) named `paisa-cli-linux-amd64`
@ -28,7 +59,7 @@
=== "Windows"
* Download the prebuilt [binary](https://github.com/ananthakumaran/paisa/releases/latest) named `paisa-cli-windows-amd64.exe`
* Since the binary is not signed with a certificate, you might get
* Since the binary is not signed[^1] with a certificate, you might get
a warning from Windows. You would have to click `keep anyway`.
* Run the following command in a Windows PowerShell. Make sure
you are in the correct folder. You should see something like `PS C:\Users\yourname>`
@ -48,7 +79,7 @@
it's not already installed on your system. If you prefer to install the
ledger yourself, follow the installation instructions on [ledger](https://www.ledger-cli.org/download.html) site.
## Quick Start
## CLI Quick Start
Paisa will store all your journals, configuration files, and other
related files in a folder named `paisa` which will be located in your
@ -73,3 +104,10 @@ related files in a folder named `paisa` which will be located in your
```
Go to [http://localhost:7500](http://localhost:7500). Read the [tutorial](./tutorial.md) to learn more.
[^1]: I offer Paisa as a free app, and I don't generate any revenue
from it. Code signing would require me to pay $99 for Mac and
approximately $300 for Windows each and every year to get the
necessary certificates. I can't justify spending that much for
an app that doesn't generate any income. Unfortunately, as a
result, you would have to jump through hoops to get it working.

View File

@ -4,17 +4,26 @@ This tutorial will introduce all the concepts necessary to get
started. **Paisa** builds on top of **ledger**, so we will first spend
some time to familiarize the [terms and concepts](https://github.com/ledger/ledger/blob/master/doc/GLOSSARY.md) used by **ledger**
## Journal
!!! tip
Even though the tutorial focuses on Indian users, Paisa is
capable of handling any currency. You can change the default
currency, locale and financial year start etc. Check the
[config](../reference/config.md) reference for more details.
## :fontawesome-regular-file-lines: Journal
A journal file captures all your financial transactions. A transaction
may represent a mutual fund purchase, EPF contribution and so
on. Let's create a file named `personal.ledger` with the following
content
may represent a mutual fund purchase, retirement contribution, grocery
purchase and so on. Paisa creates a journal named `main.ledger`, Let's
add our first transaction there. To open the editor, go to `Ledger`
:material-chevron-right: `Editor`
```ledger
2022/01/01/*(1)!*/ Salary/*(2)!*/
Income:Salary:Acme/*(3)!*/ -100,000 INR/*(6)!*/
Assets:Checking/*(4)!*/ /*(5)!*/100,000 INR
Income:Salary:Acme/*(3)!*/ -100,000 INR/*(6)!*/
Assets:Checking/*(4)!*/ /*(5)!*/100,000 INR
```
1. Transaction `Date`
@ -26,185 +35,154 @@ content
**ledger** follows the double-entry accounting system. In simple terms, it
tracks the movement of money from debit account to credit
account. Here `Income:Salary:Acme` is the debit account and
`Assets:Checking` is the credit account. The date at which the
account. Here `#!ledger Income:Salary:Acme` is the debit account and
`#!ledger Assets:Checking` is the credit account. The date at which the
transaction took place and a description of the transaction is written
in the first line followed by the list of credit or debit
entry. Account [naming conventions](../reference/accounts.md) are explained later. The `:` in the account name
represents hierarchy. Its use will become more clear when we look at
the reports generated by ledger. The sum of the balance of all the
accounts must be zero.
represents hierarchy.
```ledger
2022/01/01 Salary
Income:Salary:Acme -100,000 INR
Assets:Checking 100,000 INR
Income:Salary:Acme -100,000 INR
Assets:Checking 100,000 INR
2022/02/01 Salary
Income:Salary:Acme -100,000 INR
Assets:Checking 100,000 INR
Income:Salary:Acme -100,000 INR
Assets:Checking 100,000 INR
2022/03/01 Salary
Income:Salary:Acme -100,000 INR
Assets:Checking 100,000 INR
Income:Salary:Acme -100,000 INR
Assets:Checking 100,000 INR
```
let's add few more entries. The total balance in your savings account
could be found by
Let's add few more transactions. As you edit your journal file, the
balance of the journal will be shown on the right hand side.
```console
# ledger -f personal.ledger balance Assets:Checking
300,000 INR Assets:Checking
```
300,000 INR Assets:Checking
-300,000 INR Income:Salary:Acme
--------------------
0
```
Let's say your company deducts 12,000 INR and contributes it to EPF,
You would notice zero balance and a checking account with 3 lakhs and
an income account with -3 lakhs. Double-entry accounting will always
results in 0 balance since you have to always enter both the credit
and debit side.
Let's say your company deducts `#!ledger 12,000 INR` and contributes it to EPF,
we could represent it as follows
```ledger
2022/01/01 Salary
Income:Salary:Acme -100,000 INR
Assets:Checking 88,000 INR
Assets:Debt:EPF 12,000 INR
Income:Salary:Acme -100,000 INR
Assets:Checking 88,000 INR
Assets:Debt:EPF 12,000 INR
2022/02/01 Salary
Income:Salary:Acme -100,000 INR
Assets:Checking 88,000 INR
Assets:Debt:EPF 12,000 INR
Income:Salary:Acme -100,000 INR
Assets:Checking 88,000 INR
Assets:Debt:EPF 12,000 INR
2022/03/01 Salary
Income:Salary:Acme -100,000 INR
Assets:Checking 88,000 INR
Assets:Debt:EPF 12,000 INR
Income:Salary:Acme -100,000 INR
Assets:Checking 88,000 INR
Assets:Debt:EPF 12,000 INR
```
You can now see the use of `:` hierarchy in the account name.
```console
# ledger -f personal.ledger balance Assets
36,000 INR Assets:Debt:EPF
264,000 INR Assets:Checking
```
300,000 INR Assets
264,000 INR Checking
36,000 INR Debt:EPF
-300,000 INR Income:Salary:Acme
--------------------
300,000 INR
0
```
## Commodity
## :material-gold: Commodity
So far we have only dealt with INR. **ledger** can handle commodity as
well. Let's say you are also investing 10,000 INR in UTI Nifty Index
Fund and 10,000 INR in ICICI Nifty Next 50 Index Fund every
well. Let's say you are also investing `#!ledger 10,000 INR` in UTI Nifty Index
Fund and `#!ledger 10,000 INR` in ICICI Nifty Next 50 Index Fund every
month.
```ledger
2018/01/01 Investment
Assets:Checking -20,000 INR
Assets:Equity:NIFTY 148.0865 NIFTY @ 67.5281 INR
Assets:Equity:NIFTY_JR 358.6659 NIFTY_JR @ 27.8811 INR
Assets:Checking -20,000 INR
Assets:Equity:NIFTY 148.0865 NIFTY @ 67.5281 INR
Assets:Equity:NIFTY_JR 358.6659 NIFTY_JR @ 27.8811 INR
2018/02/01 Investment
Assets:Checking -20,000 INR
Assets:Equity:NIFTY 140.2870 NIFTY @ 71.2824 INR
Assets:Equity:NIFTY_JR 363.2242 NIFTY_JR @ 27.5312 INR
Assets:Checking -20,000 INR
Assets:Equity:NIFTY 140.2870 NIFTY @ 71.2824 INR
Assets:Equity:NIFTY_JR 363.2242 NIFTY_JR @ 27.5312 INR
2018/03/01 Investment
Assets:Checking -20,000 INR
Assets:Equity:NIFTY 147.5908 NIFTY @ 67.7549 INR
Assets:Equity:NIFTY_JR 378.4323 NIFTY_JR @ 26.4248 INR
Assets:Checking -20,000 INR
Assets:Equity:NIFTY 147.5908 NIFTY @ 67.7549 INR
Assets:Equity:NIFTY_JR 378.4323 NIFTY_JR @ 26.4248 INR
```
Let's consider `148.0865 NIFTY @ 67.5281 INR`. Here NIFTY is the name
of the commodity and we have bought 148.0865 units at 67.5281 INR per
unit.
Let's consider `#!ledger 148.0865 NIFTY @ 67.5281 INR`. Here `#!ledger
NIFTY` is the name of the commodity and we have bought `#!ledger
148.0865` units at `#!ledger 67.5281 INR` per unit.
```console
# ledger -f personal.ledger balance Assets --market
94,615 INR Assets
36,000 INR Debt:EPF
58,615 INR Equity
29,539 INR NIFTY
29,076 INR NIFTY_JR
--------------------
94,615 INR
```
Paisa has support for fetching commodity price history from few
[providers](../reference/commodities.md). Go to `Config` page and expand the `Commodities`
section. You can click the :fontawesome-solid-circle-plus: icon to
add a new one. Edit the name to `#!ledger NIFTY`. Click the
:fontawesome-solid-pen-to-square: icon near Price section and select
the price provider details. Once done, save the config and click the
`Update Prices` from the top right hand side menu. If you had done
everything correctly, you would see the latest price of the commodity
under `Assets` :material-chevron-right: `Balance`
## Interest
## :fontawesome-solid-hand-holding-dollar: Interest
There are many instruments like EPF, FD, etc that pay interest at
regular intervals. We can treat it as just another transaction.
regular intervals. We can treat it as just another transaction. Any
income account that has a prefix `#!ledger Income:Interest:` can be
used as the debit account. It's not mandatory to specify the amount at
bot side. If you leave one side, **ledger** will deduct it.
```ledger
2022/03/31 EPF Interest
Income:Interest:EPF -15,000 INR
Assets:Debt:EPF 15,000 INR
Income:Interest:EPF -5,000 INR
Assets:Debt:EPF
```
```
5,000 INR Assets:Debt:EPF
-5,000 INR Income:Interest:EPF
--------------------
0
```
## Config
So far we have been mostly talking about **ledger**. We need to know
the basics of **ledger** because **paisa** builds on top of
**ledger**.
All the configuration related to paisa is stored in a yaml file name
`paisa.yaml`. The config can be edited via the web interface. The
sequence in which it look for the file is described below
**paisa** checks for a config file named `paisa.yaml` in the current
directory. It can also be set via flag `--config` or env variable
`PAISA_CONFIG`. The default config is tuned for Indians, users from
other countries would have to change the `default_currency` and
`locale`. Check the [config](../reference/config.md) section for details.
```yaml
journal_path: /home/john/finance/personal.ledger # (1)!
db_path: /home/john/finance/paisa.db # (2)!
commodities: # (3)!
- name: NIFTY
type: mutualfund
code: 120716
- name: NIFTY_JR
type: mutualfund
code: 120684
```
1. absolute path of journal file
2. absolute path of sql database
3. commodities list
**paisa** can fetch the latest price of mutual funds, nps funds and
stocks as of today. For other types of commodities, the purchase/sell
price of the last transaction would be considered the latest
price. The code is the scheme code of the fund. The *search* command
can be used to find scheme code
```console
# paisa search mutualfund
INFO Using config file: /home/john/finance/paisa.yaml
INFO Using cached results; pass '-u' to update the cache
✔ ICICI Prudential Asset Management Company Limited
✔ ICICI Prudential Nifty Next 50 Index Fund - Direct Plan - Growth
INFO Mutual Fund Scheme Code: 120684
```
You might see a lot of funds with similar names. The search command
supports crude fuzzy search. For example, `nifty next grow direct` would match the above fund.
1. `PAISA_CONFIG` environment variable
1. via --config flag
1. Current working directory
1. `paisa/paisa.yaml` folder inside User Documents folder.
If it can't find the config file, it will create a default config file
`paisa/paisa.yaml` folder inside User Documents folder. The default
config is tuned for Indians, users from other countries would have to
change the `default_currency` and `locale`. Check the [config](../reference/config.md)
reference for details.
## Update
Once the config file is created, run the update command, followed by
the serve command.
```console
# paisa update
INFO Using config file: /home/john/finance/paisa.yaml
INFO Syncing transactions from journal
INFO Fetching commodities price history
INFO Fetching commodity NIFTY
INFO Fetching commodity NIFTY_JR
# paisa serve
INFO Using config file: /home/john/finance/paisa.yaml
INFO Listening on 7500
```
You can then go to [http://localhost:7500](http://localhost:7500) to view more
details. The update command loads all the transactions from journal
file and fetch the latest price of all the commodities. Whenever you
make any changes to the journal file, you need to run the update
command. The serve command starts a local server and provides the
Web UI.
**paisa** fetches the latest price of the commodities only when
*update* command is used. Make sure to run `paisa update` command
after you make any changes to your journal file or you want to fetch
the latest value of the commodities. The update can be performed from
the UI as well via the dropdown in the top right hand side corner.

View File

@ -25,5 +25,6 @@ class LedgerLexer(RegexLexer):
(r'[$€]', Keyword),
(r'\b[A-Z_]+\b', Keyword),
(r'^\s{4}[^\][(); \t\n]((?!\s{2})[^/\][();\t\n])*', String),
(r'^[^\][(); \t\n]((?!\s{2})[^/\][();\t\n])*$', String),
]
}

View File

@ -6,15 +6,15 @@ tracked as a commodity. Few example transactions can be found below.
```ledger
2019/02/18 NPS
Assets:Equity:NPS:SBI:E/*(1)!*/ /*(2)!*/15.9378 NPS_SBI_E/*(3)!*/ @ /*(4)!*/23.5289 INR/*(5)!*/
Assets:Equity:NPS:SBI:E/*(1)!*/ /*(2)!*/15.9378 NPS_SBI_E/*(3)!*/ @ /*(4)!*/23.5289 INR/*(5)!*/
Assets:Checking
2019/02/21 NPS
Assets:Equity:NPS:SBI:E 1557.2175 NPS_SBI_E @ 23.8406 INR
Assets:Equity:NPS:SBI:E 1557.2175 NPS_SBI_E @ 23.8406 INR
Assets:Checking
2020/06/25 Gold
Assets:Gold 40 GOLD @ 4650 INR
Assets:Gold 40 GOLD @ 4650 INR
Assets:Checking
```
@ -31,7 +31,18 @@ when you enter the second NPS transaction on `2019/02/21`, the
valuation of your existing holdings will be adjusted based on the new
purchase price.
## Mutual Fund
To link a commodity with a commodity price provider, Go to `Config`
page and expand the `Commodities` section. You can click the
:fontawesome-solid-circle-plus: icon to add a new one. Edit the name
to commodity name. Click the :fontawesome-solid-pen-to-square: icon
near Price section and select the price provider details. Once done,
save the config and click the `Update Prices` from the top right hand
side menu. If you had done everything correctly, you would see the
latest price of the commodity under `Assets` :material-chevron-right:
`Balance`. You can also view the full price history on `Ledger`
:material-chevron-right: `Price`
## Mutual Fund <sub>:flag_in:</sub>
To automatically track the latest value of your mutual funds holdings,
you need to link the commodity and the fund scheme code.
@ -40,30 +51,20 @@ you need to link the commodity and the fund scheme code.
commodities:
- name: NIFTY # (1)!
type: mutualfund # (2)!
code: 120716 # (3)!
- name: NIFTY_JR
type: mutualfund
code: 120684
price:
provider: in-mfapi # (3)!
code: 120716 # (4)!
```
1. commodity name
1. commodity type
1. price provider name
1. mutual fund scheme code
The example config above links two commodities with their respective
mutual fund scheme code. The scheme code can be found using the
*search* command.
The example config above links nifty commodity with the respective
mutual fund scheme code.
```console
# paisa search mutualfund
INFO Using config file: /home/john/finance/paisa.yaml
INFO Using cached results; pass '-u' to update the cache
✔ ICICI Prudential Asset Management Company Limited
✔ ICICI Prudential Nifty Next 50 Index Fund - Direct Plan - Growth
INFO Mutual Fund Scheme Code: 120684
```
## NPS
## NPS <sub>:flag_in:</sub>
To automatically track the latest value of your nps funds holdings,
you need to link the commodity and the fund scheme code.
@ -72,27 +73,20 @@ you need to link the commodity and the fund scheme code.
commodities:
- name: NPS_HDFC_E # (1)!
type: nps # (2)!
code: SM008002 # (3)!
price:
provider: com-purifiedbytes-nps # (3)!
code: SM008002 # (4)!
```
1. commodity name
1. type
1. price provider name
1. nps fund scheme code
The example config above links NPS fund commodity with their
respective NPS fund scheme code. The scheme code can be found using
the *search* command.
respective NPS fund scheme code.
```console
# paisa search nps
INFO Using config file: /home/john/finance/paisa.yaml
INFO Using cached results; pass '-u' to update the cache
✔ HDFC Pension Management Company Limited
✔ HDFC PENSION MANAGEMENT COMPANY LIMITED SCHEME C - TIER I
INFO NPS Fund Scheme Code: SM008002
```
## Stock
## Stock <sub>:globe_with_meridians:</sub>
To automatically track the latest value of your stock holdings,
you need to link the commodity and the stock ticker name.
@ -101,12 +95,15 @@ you need to link the commodity and the stock ticker name.
commodities:
- name: APPLE # (1)!
type: stock # (2)!
code: AAPL # (3)!
price:
provider: com-yahoo # (3)!
code: AAPL # (4)!
```
1. commodity name
2. type
3. stock ticker code
1. type
1. price provider name
1. stock ticker code
Stock prices are fetched from yahoo finance website. The ticker code
should match the code used in yahoo.
@ -140,18 +137,20 @@ default\_currency is available, paisa would work without issue
P 2023/05/01 00:00:00 USD 81.75 INR
2023/05/01 Freelance Income
;; conversion rate will be picked up from the price directive above
Income:Freelance -100 USD
;; conversion rate will be picked up
;; from the price directive above
Income:Freelance -100 USD
Assets:Checking
2023/06/01 Freelance Income
;; conversion rate is specified inline
Income:Freelance -200 USD @ 82.75 INR
Income:Freelance -200 USD @ 82.75 INR
Assets:Checking
2023/07/01 Netflix
;; if not available for a date, will use previous known conversion rate (82.75)
Expenses:Entertainment 10 USD
;; if not available for a date,
;; will use previous known conversion rate (82.75)
Expenses:Entertainment 10 USD
Assets:Checking
```
@ -161,4 +160,4 @@ P 2023/05/01 00:00:00 USD 81.75 INR
*update* command is used. Make sure to run `paisa update` command
after you make any changes to your journal file or you want to fetch
the latest value of the commodities. The update can be performed from
the UI as well via the dropdown in the top right corner.
the UI as well via the dropdown in the top right hand side corner.

View File

@ -9,7 +9,7 @@ directory. It can also be set via flag `--config` or env variable
# config file. The main journal file can refer other files using
# `include` as long as all the files are in the same or sub directory
# REQUIRED
journal_path: /home/john/finance/personal.ledger
journal_path: /home/john/finance/main.ledger
# Path to your database file. It can be absolute or relative to the
# config file. The database file will be created if it does not exist.

View File

@ -17,7 +17,7 @@
"tag_recurring": "",
"transaction_begin_line": 13,
"transaction_end_line": 15,
"file_name": "personal.ledger",
"file_name": "main.ledger",
"market_amount": 0
},
{
@ -33,14 +33,14 @@
"tag_recurring": "",
"transaction_begin_line": 13,
"transaction_end_line": 15,
"file_name": "personal.ledger",
"file_name": "main.ledger",
"market_amount": 0
}
],
"tag_recurring": "",
"beginLine": 13,
"endLine": 15,
"fileName": "personal.ledger"
"fileName": "main.ledger"
},
{
"id": "ae8edbd5-7bfc-5520-a2b5-fd1d7668adc4",
@ -60,7 +60,7 @@
"tag_recurring": "",
"transaction_begin_line": 9,
"transaction_end_line": 11,
"file_name": "personal.ledger",
"file_name": "main.ledger",
"market_amount": 0
},
{
@ -76,14 +76,14 @@
"tag_recurring": "",
"transaction_begin_line": 9,
"transaction_end_line": 11,
"file_name": "personal.ledger",
"file_name": "main.ledger",
"market_amount": 0
}
],
"tag_recurring": "",
"beginLine": 9,
"endLine": 11,
"fileName": "personal.ledger"
"fileName": "main.ledger"
},
{
"id": "97f59855-0f29-571c-a529-0fe288597b3b",
@ -103,7 +103,7 @@
"tag_recurring": "",
"transaction_begin_line": 5,
"transaction_end_line": 7,
"file_name": "personal.ledger",
"file_name": "main.ledger",
"market_amount": 0
},
{
@ -119,14 +119,14 @@
"tag_recurring": "",
"transaction_begin_line": 5,
"transaction_end_line": 7,
"file_name": "personal.ledger",
"file_name": "main.ledger",
"market_amount": 0
}
],
"tag_recurring": "",
"beginLine": 5,
"endLine": 7,
"fileName": "personal.ledger"
"fileName": "main.ledger"
},
{
"id": "185550f9-08e0-5df8-9381-21d689f889f6",
@ -146,7 +146,7 @@
"tag_recurring": "",
"transaction_begin_line": 1,
"transaction_end_line": 3,
"file_name": "personal.ledger",
"file_name": "main.ledger",
"market_amount": 0
},
{
@ -162,13 +162,13 @@
"tag_recurring": "",
"transaction_begin_line": 1,
"transaction_end_line": 3,
"file_name": "personal.ledger",
"file_name": "main.ledger",
"market_amount": 0
}
],
"tag_recurring": "",
"beginLine": 1,
"endLine": 3,
"fileName": "personal.ledger"
"fileName": "main.ledger"
}
]

View File

@ -37,10 +37,15 @@ const (
Unknown CommodityType = "unknown"
)
type Price struct {
Provider string `json:"provider" yaml:"provider"`
Code string `json:"code" yaml:"code"`
}
type Commodity struct {
Name string `json:"name" yaml:"name"`
Type CommodityType `json:"type" yaml:"type"`
Code string `json:"code" yaml:"code"`
Price Price `json:"price" yaml:"price"`
Harvest int `json:"harvest" yaml:"harvest"`
TaxCategory TaxCategoryType `json:"tax_category" yaml:"tax_category"`
}

View File

@ -75,6 +75,7 @@
],
"items": {
"type": "object",
"ui:header": "code",
"properties": {
"code": {
"type": "string",
@ -108,6 +109,7 @@
"default": [{ "name": "Debt", "target": 20, "accounts": ["Assets:Debt:*"] }],
"items": {
"type": "object",
"ui:header": "name",
"properties": {
"name": {
"type": "string"
@ -134,10 +136,15 @@
"commodities": {
"type": "array",
"default": [
{ "name": "AAPL", "type": "stock", "code": "AAPL", "tax_category": "unlisted_equity" }
{
"name": "AAPL",
"price": { "provider": "com-yahoo", "code": "AAPL" },
"tax_category": "unlisted_equity"
}
],
"items": {
"type": "object",
"ui:header": "name",
"properties": {
"name": {
"type": "string",
@ -147,9 +154,21 @@
"type": "string",
"enum": ["mutualfund", "stock", "nps", "unknown"]
},
"code": {
"type": ["string", "integer"]
"price": {
"type": "object",
"ui:widget": "price",
"properties": {
"provider": {
"type": "string",
"enum": ["in-mfapi", "com-yahoo", "com-purifiedbytes-nps"]
},
"code": {
"type": ["string", "integer"]
}
},
"required": ["provider", "code"]
},
"harvest": {
"type": "integer"
},
@ -158,7 +177,7 @@
"enum": ["", "debt", "equity", "equity65", "equity35", "unlisted_equity"]
}
},
"required": ["name", "type", "code"],
"required": ["name", "type", "price"],
"additionalProperties": false
}
}

View File

@ -43,7 +43,7 @@ journal_path: '%s'
db_path: '%s'
`
log.Info("Generating config file: ", configFilePath)
journalFilePath := filepath.Join(cwd, "personal.ledger")
journalFilePath := filepath.Join(cwd, "main.ledger")
dbFilePath := filepath.Join(cwd, "paisa.db")
err := os.WriteFile(configFilePath, []byte(fmt.Sprintf(config, journalFilePath, dbFilePath)), 0644)
if err != nil {
@ -104,31 +104,43 @@ schedule_al:
commodities:
- name: NIFTY
type: mutualfund
code: 120716
price:
provider: in-mfapi
code: 120716
harvest: 365
tax_category: equity65
- name: PPFAS
type: mutualfund
code: 122639
price:
provider: in-mfapi
code: 122639
harvest: 365
tax_category: equity65
- name: ABCBF
type: mutualfund
code: 119533
price:
provider: in-mfapi
code: 119533
harvest: 1095
tax_category: debt
- name: NPS_HDFC_E
type: nps
code: SM008001
price:
provider: com-purifiedbytes-nps
code: SM008001
- name: NPS_HDFC_C
type: nps
code: SM008002
price:
provider: com-purifiedbytes-nps
code: SM008002
- name: NPS_HDFC_G
type: nps
code: SM008003
price:
provider: com-purifiedbytes-nps
code: SM008003
`
log.Info("Generating config file: ", configFilePath)
journalFilePath := filepath.Join(cwd, "personal.ledger")
journalFilePath := filepath.Join(cwd, "main.ledger")
dbFilePath := filepath.Join(cwd, "paisa.db")
err := os.WriteFile(configFilePath, []byte(fmt.Sprintf(config, journalFilePath, dbFilePath)), 0644)
if err != nil {
@ -362,7 +374,7 @@ func emitInvestment(state *GeneratorState, start time.Time) {
}
func generateJournalFile(cwd string) {
journalFilePath := filepath.Join(cwd, "personal.ledger")
journalFilePath := filepath.Join(cwd, "main.ledger")
log.Info("Generating journal file: ", journalFilePath)
ledgerFile, err := os.OpenFile(journalFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {

View File

@ -15,7 +15,7 @@ func FindByName(name string) config.Commodity {
}
func FindByCode(name string) config.Commodity {
c, _ := lo.Find(All(), func(c config.Commodity) bool { return c.Code == name })
c, _ := lo.Find(All(), func(c config.Commodity) bool { return c.Price.Code == name })
return c
}

View File

@ -55,7 +55,7 @@ func SyncCommodities(db *gorm.DB) {
for _, commodity := range commodities {
name := commodity.Name
log.Info("Fetching commodity ", aurora.Bold(name))
code := commodity.Code
code := commodity.Price.Code
var prices []*price.Price
var err error
@ -93,12 +93,12 @@ func SyncPortfolios(db *gorm.DB) {
for _, commodity := range commodities {
name := commodity.Name
log.Info("Fetching portfolio for ", aurora.Bold(name))
portfolios, err := mutualfund.GetPortfolio(commodity.Code, commodity.Name)
portfolios, err := mutualfund.GetPortfolio(commodity.Price.Code, commodity.Name)
if err != nil {
log.Fatal(err)
}
portfolio.UpsertAll(db, commodity.Type, commodity.Code, portfolios)
portfolio.UpsertAll(db, commodity.Type, commodity.Price.Code, portfolios)
}
}

View File

@ -1,6 +1,8 @@
package scheme
import (
"github.com/ananthakumaran/paisa/internal/model/price"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
@ -42,23 +44,18 @@ func UpsertAll(db *gorm.DB, schemes []*Scheme) {
}
}
func GetAMCs(db *gorm.DB) []string {
func GetAMCCompletions(db *gorm.DB) []price.AutoCompleteItem {
var amcs []string
db.Model(&Scheme{}).Distinct().Pluck("AMC", &amcs)
return amcs
return lo.Map(amcs, func(amc string, _ int) price.AutoCompleteItem {
return price.AutoCompleteItem{Label: amc, ID: amc}
})
}
func GetNAVNames(db *gorm.DB, amc string) []string {
var navNames []string
db.Model(&Scheme{}).Where("amc = ? and type = 'Open Ended'", amc).Pluck("NAVName", &navNames)
return navNames
}
func FindScheme(db *gorm.DB, amc string, NAVName string) Scheme {
var scheme Scheme
result := db.Where("amc = ? and nav_name = ?", amc, NAVName).First(&scheme)
if result.Error != nil {
log.Fatal(result.Error)
}
return scheme
func GetNAVNameCompletions(db *gorm.DB, amc string) []price.AutoCompleteItem {
var schemes []Scheme
db.Model(&Scheme{}).Where("amc = ? and type = 'Open Ended'", amc).Find(&schemes)
return lo.Map(schemes, func(scheme Scheme, _ int) price.AutoCompleteItem {
return price.AutoCompleteItem{Label: scheme.NAVName, ID: scheme.Code}
})
}

View File

@ -1,6 +1,8 @@
package scheme
import (
"github.com/ananthakumaran/paisa/internal/model/price"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
@ -43,23 +45,18 @@ func UpsertAll(db *gorm.DB, schemes []*Scheme) {
}
}
func GetPFMs(db *gorm.DB) []string {
func GetPFMCompletions(db *gorm.DB) []price.AutoCompleteItem {
var pfms []string
db.Model(&Scheme{}).Distinct().Pluck("PFMName", &pfms)
return pfms
return lo.Map(pfms, func(pfm string, _ int) price.AutoCompleteItem {
return price.AutoCompleteItem{Label: pfm, ID: pfm}
})
}
func GetSchemeNames(db *gorm.DB, pfm string) []string {
var schemeNames []string
db.Model(&Scheme{}).Where("pfm_name = ?", pfm).Pluck("SchemeName", &schemeNames)
return schemeNames
}
func FindScheme(db *gorm.DB, pfm string, schemeName string) Scheme {
var scheme Scheme
result := db.Where("pfm_name = ? and scheme_name = ?", pfm, schemeName).First(&scheme)
if result.Error != nil {
log.Fatal(result.Error)
}
return scheme
func GetSchemeNameCompletions(db *gorm.DB, pfm string) []price.AutoCompleteItem {
var schemes []Scheme
db.Model(&Scheme{}).Where("pfm_name = ?", pfm).Find(&schemes)
return lo.Map(schemes, func(scheme Scheme, _ int) price.AutoCompleteItem {
return price.AutoCompleteItem{Label: scheme.SchemeName, ID: scheme.SchemeID}
})
}

View File

@ -0,0 +1,23 @@
package price
import "gorm.io/gorm"
type AutoCompleteItem struct {
Label string `json:"label"`
ID string `json:"id"`
}
type AutoCompleteField struct {
Label string `json:"label"`
ID string `json:"id"`
Help string `json:"help"`
InputType string `json:"inputType"`
}
type PriceProvider interface {
Code() string
Label() string
AutoCompleteFields() []AutoCompleteField
AutoComplete(db *gorm.DB, field string, filter map[string]string) []AutoCompleteItem
ClearCache(db *gorm.DB)
}

View File

@ -0,0 +1,51 @@
package mutualfund
import (
"github.com/ananthakumaran/paisa/internal/model/mutualfund/scheme"
"github.com/ananthakumaran/paisa/internal/model/price"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type PriceProvider struct {
}
func (p *PriceProvider) Code() string {
return "in-mfapi"
}
func (p *PriceProvider) Label() string {
return "MF API India"
}
func (p *PriceProvider) AutoCompleteFields() []price.AutoCompleteField {
return []price.AutoCompleteField{
{Label: "AMC", ID: "amc", Help: "Asset Management Company"},
{Label: "Fund Name", ID: "scheme"},
}
}
func (p *PriceProvider) AutoComplete(db *gorm.DB, field string, filter map[string]string) []price.AutoCompleteItem {
count := scheme.Count(db)
if count == 0 {
schemes, err := GetSchemes()
if err != nil {
log.Fatal(err)
}
scheme.UpsertAll(db, schemes)
} else {
log.Info("Using cached results")
}
switch field {
case "amc":
return scheme.GetAMCCompletions(db)
case "scheme":
return scheme.GetNAVNameCompletions(db, filter["amc"])
}
return []price.AutoCompleteItem{}
}
func (p *PriceProvider) ClearCache(db *gorm.DB) {
db.Exec("DELETE FROM schemes")
}

View File

@ -0,0 +1,51 @@
package nps
import (
"github.com/ananthakumaran/paisa/internal/model/nps/scheme"
"github.com/ananthakumaran/paisa/internal/model/price"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type PriceProvider struct {
}
func (p *PriceProvider) Code() string {
return "com-purifiedbytes-nps"
}
func (p *PriceProvider) Label() string {
return "Purified Bytes NPS India"
}
func (p *PriceProvider) AutoCompleteFields() []price.AutoCompleteField {
return []price.AutoCompleteField{
{Label: "PFM", ID: "pfm", Help: "Pension Fund Manager"},
{Label: "Scheme Name", ID: "scheme"},
}
}
func (p *PriceProvider) AutoComplete(db *gorm.DB, field string, filter map[string]string) []price.AutoCompleteItem {
count := scheme.Count(db)
if count == 0 {
schemes, err := GetSchemes()
if err != nil {
log.Fatal(err)
}
scheme.UpsertAll(db, schemes)
} else {
log.Info("Using cached results")
}
switch field {
case "pfm":
return scheme.GetPFMCompletions(db)
case "scheme":
return scheme.GetSchemeNameCompletions(db, filter["pfm"])
}
return []price.AutoCompleteItem{}
}
func (p *PriceProvider) ClearCache(db *gorm.DB) {
db.Exec("DELETE FROM nps_schemes")
}

View File

@ -0,0 +1,31 @@
package scraper
import (
"github.com/ananthakumaran/paisa/internal/model/price"
"github.com/ananthakumaran/paisa/internal/scraper/mutualfund"
"github.com/ananthakumaran/paisa/internal/scraper/nps"
"github.com/ananthakumaran/paisa/internal/scraper/stock"
log "github.com/sirupsen/logrus"
)
func GetAllProviders() []price.PriceProvider {
return []price.PriceProvider{
&mutualfund.PriceProvider{},
&nps.PriceProvider{},
&stock.PriceProvider{},
}
}
func GetProviderByCode(code string) price.PriceProvider {
switch code {
case "in-mfapi":
return &mutualfund.PriceProvider{}
case "com-purifiedbytes-nps":
return &nps.PriceProvider{}
case "com-yahoo":
return &stock.PriceProvider{}
}
log.Fatal("Unknown price provider: ", code)
return nil
}

View File

@ -0,0 +1,30 @@
package stock
import (
"github.com/ananthakumaran/paisa/internal/model/price"
"gorm.io/gorm"
)
type PriceProvider struct {
}
func (p *PriceProvider) Code() string {
return "com-yahoo"
}
func (p *PriceProvider) Label() string {
return "Yahoo Finance"
}
func (p *PriceProvider) AutoCompleteFields() []price.AutoCompleteField {
return []price.AutoCompleteField{
{Label: "Ticker", ID: "ticker", Help: "Stock ticker symbol", InputType: "text"},
}
}
func (p *PriceProvider) AutoComplete(db *gorm.DB, field string, filter map[string]string) []price.AutoCompleteItem {
return []price.AutoCompleteItem{}
}
func (p *PriceProvider) ClearCache(db *gorm.DB) {
}

View File

@ -142,7 +142,7 @@ func GetAccountPortfolioAllocation(db *gorm.DB, account string) PortfolioAllocat
func computePortfolioAggregate(db *gorm.DB, commodityName string, total decimal.Decimal) []CommodityBreakdown {
commodity := commodity.FindByName(commodityName)
portfolios := portfolio.GetPortfolios(db, commodity.Code)
portfolios := portfolio.GetPortfolios(db, commodity.Price.Code)
return lo.Map(portfolios, func(p portfolio.Portfolio, _ int) CommodityBreakdown {
amount := (total.Mul(p.Percentage)).Div(decimal.NewFromInt(100))
return CommodityBreakdown{

View File

@ -1,11 +1,15 @@
package server
import (
"strings"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"github.com/ananthakumaran/paisa/internal/config"
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/model/price"
"github.com/ananthakumaran/paisa/internal/scraper"
"github.com/ananthakumaran/paisa/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@ -24,3 +28,44 @@ func GetPrices(db *gorm.DB) gin.H {
}
return gin.H{"prices": prices}
}
type AutoCompleteRequest struct {
Provider string `json:"provider"`
Field string `json:"field"`
Filters map[string]string `json:"filters"`
}
func GetPriceProviders(db *gorm.DB) gin.H {
providers := scraper.GetAllProviders()
return gin.H{
"providers": lo.Map(providers, func(provider price.PriceProvider, _ int) gin.H {
return gin.H{"code": provider.Code(), "label": provider.Label(), "fields": provider.AutoCompleteFields()}
}),
}
}
func ClearPriceProviderCache(db *gorm.DB, code string) gin.H {
provider := scraper.GetProviderByCode(code)
provider.ClearCache(db)
return gin.H{}
}
func GetPriceAutoCompletions(db *gorm.DB, request AutoCompleteRequest) gin.H {
provider := scraper.GetProviderByCode(request.Provider)
completions := provider.AutoComplete(db, request.Field, request.Filters)
completions = lo.Filter(completions, func(completion price.AutoCompleteItem, _ int) bool {
item := completion.Label
item = strings.Replace(strings.ToLower(item), " ", "", -1)
words := strings.Split(strings.ToLower(request.Filters[request.Field]), " ")
for _, word := range words {
if strings.TrimSpace(word) != "" && !strings.Contains(item, word) {
return false
}
}
return true
})
return gin.H{"completions": completions}
}

View File

@ -116,6 +116,24 @@ func Build(db *gorm.DB) *gin.Engine {
router.GET("/api/price", func(c *gin.Context) {
c.JSON(200, GetPrices(db))
})
router.GET("/api/price/providers", func(c *gin.Context) {
c.JSON(200, GetPriceProviders(db))
})
router.POST("/api/price/providers/delete/:provider", func(c *gin.Context) {
provider := c.Param("provider")
c.JSON(200, ClearPriceProviderCache(db, provider))
})
router.POST("/api/price/autocomplete", func(c *gin.Context) {
var autoCompleteRequest AutoCompleteRequest
if err := c.ShouldBindJSON(&autoCompleteRequest); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(200, GetPriceAutoCompletions(db, autoCompleteRequest))
})
router.GET("/api/transaction", func(c *gin.Context) {
c.JSON(200, GetTransactions(db))
})
@ -233,7 +251,7 @@ func Build(db *gorm.DB) *gin.Engine {
func Listen(db *gorm.DB, port int) {
router := Build(db)
log.Info("Listening on ", port)
log.Infof("Listening on http://localhost:%d", port)
err := router.Run(fmt.Sprintf(":%d", port))
if err != nil {
log.Fatal(err)

View File

@ -56,7 +56,7 @@ theme:
- search.highlight
markdown_extensions:
- md_in_html
- footnotes
- pymdownx.tabbed:
alternate_style: true
- pymdownx.highlight:

View File

@ -20,7 +20,7 @@ describe("bulk_editor", () => {
const after = fs.readFileSync(`fixture/bulk_edit/${dir}/${name}.ledger`).toString();
const ledgerFile: LedgerFile = {
type: "file",
name: "personal.ledger",
name: "main.ledger",
content: before.toString(),
versions: []
};

View File

@ -1,13 +1,22 @@
<script lang="ts">
import type { JSONSchema7 } from "json-schema";
import _ from "lodash";
import PriceCodeSearchModal from "./PriceCodeSearchModal.svelte";
interface Schema extends JSONSchema7 {
"ui:header"?: string;
"ui:widget"?: string;
}
export let key: string;
export let value: any;
export let schema: JSONSchema7;
export let schema: Schema;
export let depth: number = 0;
export let required = false;
export let deletable: () => void = null;
export let disabled: boolean = false;
export let modalOpen = false;
let open = depth < 1;
$: title = _.startCase(key);
@ -16,20 +25,17 @@
return _.cloneDeep(schema.default[0]);
}
function sortedProperties(schema: JSONSchema7) {
return _.sortBy(
Object.entries(schema.properties),
([key, subSchema]: [string, JSONSchema7]) => {
return [
_.includes(schema.required || [], key) ? 0 : 1,
subSchema.type == "object" ? 2 : subSchema.type == "array" ? 3 : 1,
key
];
}
);
function sortedProperties(schema: Schema) {
return _.sortBy(Object.entries(schema.properties), ([key, subSchema]: [string, Schema]) => {
return [
_.includes(schema.required || [], key) ? 0 : 1,
subSchema.type == "object" ? 2 : subSchema.type == "array" ? 3 : 1,
key
];
});
}
function documentation(schema: JSONSchema7) {
function documentation(schema: Schema) {
if (schema.description) {
return `<p style="max-width: 300px">${schema.description}</p>`;
}
@ -55,7 +61,7 @@
<div class="control">
{#if schema.enum}
<div class="select is-small">
<select bind:value {required}>
<select {disabled} bind:value {required}>
{#each schema.enum as option}
<option value={option}>{option}</option>
{/each}
@ -63,6 +69,7 @@
</div>
{:else}
<input
{disabled}
{required}
pattern={schema.pattern}
class="input is-small"
@ -97,6 +104,39 @@
</div>
</div>
</div>
{:else if schema["ui:widget"] == "price"}
<div class="config-header">
<a class="is-link" data-tippy-content={documentation(schema)}>
<span>{title}</span>
</a>
<a on:click={(_e) => (modalOpen = true)} class="is-link">
<span class="icon is-small">
<i class="fas fa-pen-to-square"></i>
</span>
</a>
</div>
<PriceCodeSearchModal
bind:open={modalOpen}
on:select={(e) => {
value["code"] = e.detail.code;
value["provider"] = e.detail.provider;
}}
/>
<div class="config-body {depth % 2 == 1 ? 'odd' : 'even'}">
{#each sortedProperties(schema) as [key, subSchema]}
<svelte:self
required={_.includes(schema.required || [], key)}
depth={depth + 1}
{key}
bind:value={value[key]}
schema={subSchema}
disabled={true}
/>
{/each}
</div>
{:else if schema.type == "object"}
<div class="config-header">
<a
@ -104,7 +144,7 @@
data-tippy-content={documentation(schema)}
on:click={(_e) => (open = !open)}
>
<span>{value.name || value.code || title}</span>
<span>{schema["ui:header"] ? value[schema["ui:header"]] || title : title}</span>
<span class="icon is-small">
<i class="fas {open ? 'fa-angle-up' : 'fa-angle-down'}"></i>
</span>

View File

@ -0,0 +1,162 @@
<script lang="ts">
import Select from "svelte-select";
import Modal from "$lib/components/Modal.svelte";
import _ from "lodash";
import { createEventDispatcher, onMount } from "svelte";
import { ajax, type AutoCompleteItem, type PriceProvider } from "$lib/utils";
let label = "Choose Price Provider";
export let open = false;
let code = "";
let providers: PriceProvider[] = [];
let selectedProvider: PriceProvider = null;
let filters: Record<string, AutoCompleteItem> = {};
onMount(async () => {
({ providers } = await ajax("/api/price/providers"));
selectedProvider = providers[0];
});
let isLoading = false;
async function clearProviderCache() {
isLoading = true;
try {
await ajax(
"/api/price/providers/delete/:provider",
{ method: "POST" },
{ provider: selectedProvider.code }
);
} finally {
isLoading = false;
}
}
let autocompleteCache: number[] = [];
function clearCache(i: number) {
autocompleteCache[i] = (autocompleteCache[i] || 0) + 1;
}
function reset() {
code = "";
filters = {};
for (let i = 0; i < _.max(_.map(providers, (p) => p.fields.length)); i++) {
clearCache(i);
}
}
function makeAutoComplete(
field: string,
filters: Record<string, AutoCompleteItem>,
i: number,
provider: PriceProvider
) {
return async function autocomplete(filterText: string): Promise<AutoCompleteItem[]> {
for (let j = 0; j < i; j++) {
if (_.isEmpty(filters[provider.fields[j].id])) {
return [];
}
}
const queryFilters = _.mapValues(filters, (v) => v?.id);
queryFilters[field] = filterText;
const { completions } = await ajax("/api/price/autocomplete", {
method: "POST",
body: JSON.stringify({
field,
provider: selectedProvider.code,
filters: queryFilters
})
});
return completions;
};
}
const dispatch = createEventDispatcher();
</script>
<Modal bind:active={open} footerClass="is-justify-content-space-between">
<svelte:fragment slot="head" let:close>
<p class="modal-card-title">{label}</p>
<button class="delete" aria-label="close" on:click={(e) => close(e)} />
</svelte:fragment>
<div style="min-height: 500px;" slot="body">
{#if selectedProvider}
<div class="field">
<label class="label" for="price-provider">Provider</label>
<div class="control" id="price-provider">
<div class="select">
<select bind:value={selectedProvider} required on:change={(_e) => reset()}>
{#each providers as provider}
<option value={provider}>{provider.label}</option>
{/each}
</select>
</div>
</div>
</div>
<div class="field">
{#each selectedProvider.fields as field, i}
<div class="field">
<label class="label" for="">{field.label}</label>
<div class="control">
{#if field.inputType == "text"}
<input class="input" type="text" bind:value={code} required />
{:else}
{#key autocompleteCache[i]}
<Select
bind:value={filters[field.id]}
--list-z-index="5"
showChevron={true}
loadOptions={makeAutoComplete(field.id, filters, i, selectedProvider)}
label="label"
itemId="id"
searchable={true}
clearable={false}
on:change={() => {
_.each(selectedProvider.fields, (f, j) => {
if (j > i) {
clearCache(j);
filters[f.id] = null;
}
});
if (i === selectedProvider.fields.length - 1) {
code = filters[field.id].id;
} else {
code = "";
}
}}
></Select>
{/key}
{/if}
<p class="help">{field.help}</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
<svelte:fragment slot="foot" let:close>
<div>
<button
class="button is-success"
disabled={_.isEmpty(code)}
on:click={(e) => {
dispatch("select", { code: code, provider: selectedProvider.code });
reset();
close(e);
}}>Select</button
>
<button class="button" on:click={(e) => close(e)}>Cancel</button>
</div>
<div>
<button
on:click={(_e) => clearProviderCache()}
class="button is-danger {isLoading && 'is-loading'}"
disabled={!selectedProvider}>Clear Provider Cache</button
>
</div>
</svelte:fragment>
</Modal>

View File

@ -5,6 +5,24 @@ import * as d3 from "d3";
import { loading } from "../store";
import type { JSONSchema7 } from "json-schema";
export interface AutoCompleteItem {
label: string;
id: string;
}
export interface AutoCompleteField {
id: string;
label: string;
help: string;
inputType: string;
}
export interface PriceProvider {
code: string;
fields: AutoCompleteField[];
label: string;
}
export interface Posting {
id: string;
date: dayjs.Dayjs;
@ -386,7 +404,9 @@ const BACKGROUND = [
"/api/editor/file/delete_backups",
"/api/templates",
"/api/templates/upsert",
"/api/templates/delete"
"/api/templates/delete",
"/api/price/autocomplete",
"/api/price/providers/delete/:provider"
];
export function ajax(route: "/api/config"): Promise<{ config: UserConfig; schema: JSONSchema7 }>;
@ -437,6 +457,7 @@ export function ajax(
portfolio_allocation: PortfolioAllocation;
asset_breakdown: AssetBreakdown;
}>;
export function ajax(route: "/api/allocation"): Promise<{
aggregates: { [key: string]: Aggregate };
aggregates_timeline: { [key: string]: Aggregate }[];
@ -514,6 +535,25 @@ export function ajax(
): Promise<{ file: LedgerFile }>;
export function ajax(route: "/api/sync", options?: RequestInit): Promise<any>;
export function ajax(
route: "/api/price/providers",
options?: RequestInit
): Promise<{ providers: PriceProvider[] }>;
export function ajax(
route: "/api/price/providers/delete/:provider",
options?: RequestInit,
params?: Record<string, string>
): Promise<{
gain_timeline_breakdown: AccountGain;
portfolio_allocation: PortfolioAllocation;
asset_breakdown: AssetBreakdown;
}>;
export function ajax(
route: "/api/price/autocomplete",
options?: RequestInit
): Promise<{ completions: AutoCompleteItem[] }>;
export function ajax(route: "/api/init", options?: RequestInit): Promise<any>;
export function ajax(