Compare commits

...

5 Commits

Author SHA1 Message Date
Anantha Kumaran 46b4820bce [docs] add credit card 2024-01-27 12:09:27 +05:30
Anantha Kumaran 4bcf2cce8c add new blog 2024-01-26 20:35:37 +05:30
Anantha Kumaran 8942670f22 fix xirr calculation when capital gains is involved 2024-01-26 19:47:11 +05:30
Anantha Kumaran 78fe6dcacf include credit card to demo 2024-01-26 15:54:45 +05:30
Anantha Kumaran 3b985c9716 credit card 2024-01-26 15:19:23 +05:30
43 changed files with 1566 additions and 104 deletions

View File

@ -6,6 +6,7 @@ categories:
- locale
hide:
- feedback
description: "How to setup Paisa for your country."
---
# Localization

View File

@ -0,0 +1,224 @@
---
date: 2024-01-26
comments: true
categories:
- sovereign-gold-bond
- gold
- sgb
- price
hide:
- feedback
description: "How to track Sovereign Gold Bond price and calculate returns."
---
# Sovereign Gold Bond
Reserve Bank of India started issuing a new type of bond called
[Sovereign Gold Bond](https://www.rbi.org.in/commonperson/English/Scripts/FAQs.aspx?Id=1658) (SGB) in November 2015. The bond is issued by
RBI on behalf of the Government of India. Each unit of the bond is
equivalent to a gram of gold. The price of the bond is linked to the
price of the gold. The price is calculated by taking the average of
the closing price of gold of 999 purity for the last 3 business days
of the week. RBI follows the price of gold published by [IBJA](https://ibja.co/).
<!-- more -->
The bond also includes an interest component. The interest is paid
every 6 months. The initial bonds were issued with 2.75%
interest. Nowadays the interest is 2.5%. The interest is taxable as
per the income tax slab of the investor. Any capital gains on the bond
are exempted if you hold the bond till maturity. The bond has an 8
year term with an exit option available from the 5<sup>th</sup> year
onward. In this blog post, we'll explore how to use Paisa to
effectively track your SGBs and calculate your returns.
Let's assume you have `#!ledger 25,000 INR` in your bank account.
```ledger
2015/11/01 Opening Balance
Assets:Checking:SBI 25000.00 INR
Equity:OpeningBalance
```
Let's assume you bought 8 units of SGB issued on 26<sup>th</sup>
November 2015. The price of a single unit was `#!ledger 2684 INR`.
```ledger
2015/11/26 Buy Sovereign Gold Bond
Assets:Gold:SGB 8 SGB @ 2684.00 INR
Assets:Checking:SBI -21472.00 INR
```
If you go to the `#!ledger Assets:Gold:SGB` account, you'll see the
following
![Initial Balance](../../images/sgb/balance-1.png)
Let's add the interest component to the bond. The interest is paid on
26<sup>th</sup> of May and November every year. We need to make two
transactions entries for each interest payment. One from `#!ledger
Income:Interest:Gold:SGB` to `#!ledger Assets:Gold:SGB` and another
from `#!ledger Assets:Gold:SGB` to `#!ledger
Assets:Checking:SBI`. This is necessary to calculate the correct
returns. If you send the interest directly to your bank account, Paisa
will not be able to calculate the correct returns for `#!ledger
Income:Interest:Gold:SGB` account.
```ledger
2016/05/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2016/05/26 SGB Interest Credit
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2016/11/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2016/11/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2017/05/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2017/05/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2017/11/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2017/11/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2018/05/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2018/05/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2018/11/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2018/11/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2019/05/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2019/05/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2019/11/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2019/11/26 SGB Interest Withdrawal
Assets:Gold:SGB 295.24 INR
Assets:Checking:SBI
2020/05/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2020/05/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2020/11/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2020/11/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2021/05/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2021/05/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2021/11/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2021/11/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2022/05/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2022/05/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2022/11/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2022/11/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2023/05/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2023/05/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
2023/11/26 SGB Interest Credit
Assets:Gold:SGB 295.24 INR
Income:Interest:Gold:SGB
2023/11/26 SGB Interest Withdrawal
Assets:Gold:SGB -295.24 INR
Assets:Checking:SBI
```
The final part is the bond settlement. The debit posting includes the
buy price, buy date and the sell price. The gains come from the
capital gains account.
```ledger
2023/11/30 Withdraw Sovereign Gold Bond
Assets:Gold:SGB -8 SGB {2684.00 INR} [2015/11/26] @ 6132.00 INR
Income:CapitalGains:Gold:SGB -27584 INR
Assets:Checking:SBI 49056.00 INR
```
It's also possible to track the daily price[^1] of the bond. Since the
bond price is linked to the price of gold, we can use the IBJA gold
price to track the price of the bond. Go to the configuration page and
add a new commodity with the following information.
![Commodity Settings](../../images/sgb/config.png)
If you go to the `#!ledger Assets:Gold:SGB` account, You can find the
total returns, XIRR and the change of the bond value over time.
![Final Balance](../../images/sgb/balance-2.png)
[^1]: The daily price history of IBJA gold price is available only
from 2022. If you have access to the price history, you can ping
me. Alternatively, you can manually add the [price history](../../reference/commodities.md#realestate) to the
ledger file.

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

BIN
docs/images/sgb/config.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -269,4 +269,20 @@ user_accounts:
password: sha256:a96dc73edd639b1c711b006e714bd2ff5bf5c1aecd77d0b3c3370403c66d58e5
# Required, password hashed twice with sha256, then prefixed sha256:
# echo -n 'secret' | sha256sum | head -c 64 | sha256sum | head -c 64
## List of credit cards
# OPTIONAL, DEFAULT: []
credit_cards:
- account: Liabilities:CreditCard:Freedom
# Required, account name
credit_limit: 150000
# Required, credit limit of the card
statement_end_day: 8
# Required, the day of the month when the statement is generated
due_day: 20
# Required, the day of the month when the payment is due
network: visa
# Required, the network of the card
number: "0007"
# Required, the last 4 digits of the card number
```

View File

@ -0,0 +1,36 @@
---
description: "How to configure credit cards in Paisa"
---
# Credit Cards
Paisa allows you to track your credit card bills and payments. Let's
say you have a liability account called
`Liabilities:CreditCard:Freedom` to track your real world credit card,
you can configure it in Paisa as follows:
```yaml
credit_cards:
- account: Liabilities:CreditCard:Freedom #(1)!
credit_limit: 150000 #(2)!
statement_end_day: 8 #(3)!
due_day: 20 #(4)!
network: visa #(5)!
number: "0007" #(6)!
```
1. Account name
2. Credit limit of the card
3. The day of the month when the statement is generated
4. The day of the month when the payment is due
5. The network of the card
6. The last 4 digits of the card number
The above configuration can be done from the `More > Configuration`
page. Expand the `Credit Cards` section and click
:fontawesome-solid-circle-plus: icon to add a new one
Once configured, the credit card will show up on the `Liabilities >
Credit Cards` page. Paisa will automatically calculate and display
various information like the amount due, payment due date, credit
limit utilized, etc.

View File

@ -323,3 +323,7 @@
background-color: var(--account-color-income-background);
color: var(--account-color-income);
}
.language-ledger pre code {
max-height: 500px;
}

View File

@ -7,6 +7,7 @@ import (
"path/filepath"
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/model/transaction"
"github.com/ananthakumaran/paisa/internal/service"
"github.com/ananthakumaran/paisa/internal/utils"
"github.com/samber/lo"
@ -186,6 +187,11 @@ func RunningBalance(db *gorm.DB, postings []posting.Posting) []Point {
return series
}
func SortTransactionAsc(transactions []transaction.Transaction) []transaction.Transaction {
sort.Slice(transactions, func(i, j int) bool { return transactions[i].Date.Before(transactions[j].Date) })
return transactions
}
func SortAsc(postings []posting.Posting) []posting.Posting {
sort.Slice(postings, func(i, j int) bool { return postings[i].Date.Before(postings[j].Date) })
return postings
@ -212,3 +218,13 @@ func GroupByAccount(posts []posting.Posting) map[string][]posting.Posting {
return post.Account
})
}
func GroupByMonthlyBillingCycle(postsings []posting.Posting, billDate int) map[string][]posting.Posting {
return lo.GroupBy(postsings, func(p posting.Posting) string {
if p.Date.Day() > billDate {
return utils.BeginningOfMonth(p.Date).AddDate(0, 1, 0).Format("2006-01")
} else {
return p.Date.Format("2006-01")
}
})
}

View File

@ -2,6 +2,7 @@ package cache
import (
"github.com/ananthakumaran/paisa/internal/accounting"
"github.com/ananthakumaran/paisa/internal/model/transaction"
"github.com/ananthakumaran/paisa/internal/prediction"
"github.com/ananthakumaran/paisa/internal/service"
)
@ -11,4 +12,5 @@ func Clear() {
service.ClearPriceCache()
accounting.ClearCache()
prediction.ClearCache()
transaction.ClearCache()
}

View File

@ -115,6 +115,15 @@ type AllocationTarget struct {
Accounts []string `json:"accounts" yaml:"accounts"`
}
type CreditCard struct {
Account string `json:"account" yaml:"account"`
CreditLimit int `json:"credit_limit" yaml:"credit_limit"`
StatementEndDay int `json:"statement_end_day" yaml:"statement_end_day"`
DueDay int `json:"due_day" yaml:"due_day"`
Network string `json:"network" yaml:"network"`
Number string `json:"number" yaml:"number"`
}
type Config struct {
JournalPath string `json:"journal_path" yaml:"journal_path"`
DBPath string `json:"db_path" yaml:"db_path"`
@ -143,6 +152,8 @@ type Config struct {
Goals Goals `json:"goals" yaml:"goals"`
UserAccounts []UserAccount `json:"user_accounts" yaml:"user_accounts"`
CreditCards []CreditCard `json:"credit_cards" yaml:"credit_cards"`
}
var config Config
@ -166,6 +177,7 @@ var defaultConfig = Config{
Accounts: []Account{},
Goals: Goals{Retirement: []RetirementGoal{}, Savings: []SavingsGoal{}},
UserAccounts: []UserAccount{},
CreditCards: []CreditCard{},
}
var itemsUniquePropertiesMeta = jsonschema.MustCompileString("itemsUniqueProperties.json", `{

View File

@ -347,7 +347,7 @@
"properties": {
"name": {
"type": "string",
"description": "name of the commodity"
"description": "Name of the commodity"
},
"type": {
"type": "string",
@ -401,7 +401,7 @@
"properties": {
"name": {
"type": "string",
"description": "name of the template",
"description": "Name of the template",
"minLength": 1
},
"content": {
@ -428,7 +428,7 @@
"properties": {
"name": {
"type": "string",
"description": "name of the account",
"description": "Name of the account",
"minLength": 1
},
"icon": {
@ -440,6 +440,66 @@
"required": ["name"],
"additionalProperties": false
}
},
"credit_cards": {
"type": "array",
"itemsUniqueProperties": ["account"],
"default": [
{
"account": "Liabilities:CreditCard:Chase",
"credit_limit": 100000,
"statement_end_day": 28,
"due_day": 15
}
],
"items": {
"type": "object",
"ui:header": "account",
"properties": {
"account": {
"type": "string",
"description": "Name of the credit card account"
},
"credit_limit": {
"type": "number",
"description": "Credit limit of the card",
"minimum": 1
},
"statement_end_day": {
"type": "integer",
"description": "Statement end day of the card",
"minimum": 1,
"maximum": 31
},
"due_day": {
"type": "integer",
"description": "Due day of the card",
"minimum": 1,
"maximum": 31
},
"network": {
"type": "string",
"description": "Network of the card",
"enum": ["visa", "mastercard", "dinersclub", "amex", "rupay", "jcb", "discover"]
},
"number": {
"type": "string",
"description": "Last 4 digits of the card number",
"maxLength": 4,
"minLength": 4,
"pattern": "^[0-9]{4}$"
}
},
"required": [
"account",
"credit_limit",
"statement_end_day",
"due_day",
"network",
"number"
],
"additionalProperties": false
}
}
},
"required": ["journal_path", "db_path"],

View File

@ -25,13 +25,14 @@ import (
const START_YEAR = 2014
type GeneratorState struct {
Balance float64
EPFBalance float64
Ledger *os.File
YearlySalary float64
Rent float64
LoanBalance float64
NiftyBalance float64
Balance float64
CreditBalance float64
EPFBalance float64
Ledger *os.File
YearlySalary float64
Rent float64
LoanBalance float64
NiftyBalance float64
}
var pricesTree map[string]*btree.BTree
@ -102,6 +103,9 @@ allocation_targets:
target: 60
accounts:
- Assets:Equity:*
accounts:
- name: Liabilities:CreditCard:Freedom
icon: arcticons:chase
schedule_al:
- code: bank
accounts:
@ -153,6 +157,13 @@ commodities:
price:
provider: com-purifiedbytes-nps
code: SM008003
credit_cards:
- account: Liabilities:CreditCard:Freedom
credit_limit: 150000
statement_end_day: 8
due_day: 20
network: visa
number: "0007"
`
log.Info("Generating config file: ", configFilePath)
journalFilePath := filepath.Join(cwd, "main.ledger")
@ -326,18 +337,30 @@ func emitExpense(state *GeneratorState, start time.Time) {
}
emit := func(payee string, account string, amount float64, fuzz float64) {
actualAmount := roundToK(percentRange(int(fuzz*100), 100) * amount)
var actualAmount float64
if fuzz == 1 {
actualAmount = amount
} else {
actualAmount = roundToK(percentRange(int(fuzz*100), 100) * amount)
}
start = start.AddDate(0, 0, 1)
emitTransaction(state.Ledger, start, payee, "Assets:Checking:SBI", account, actualAmount)
state.Balance -= actualAmount
}
emit("Rent", "Expenses:Rent", state.Rent, 1.0)
emit("Internet", "Expenses:Utilities", 1500, 1.0)
emit("Mobile", "Expenses:Utilities", 430, 1.0)
emit("Shopping", "Expenses:Shopping", 3000, 0.5)
emit("Eat out", "Expenses:Restaurants", 2500, 0.5)
emit("Groceries", "Expenses:Food", 5000, 0.9)
emitExpense := func(payee string, account string, amount float64, fuzz float64) {
actualAmount := roundToK(percentRange(int(fuzz*100), 100) * amount)
start = start.AddDate(0, 0, 1)
emitTransaction(state.Ledger, start, payee, "Liabilities:CreditCard:Freedom", account, actualAmount)
state.CreditBalance -= actualAmount
}
emitExpense("Rent", "Expenses:Rent", state.Rent, 1.0)
emitExpense("Internet", "Expenses:Utilities", 1500, 1.0)
emitExpense("Mobile", "Expenses:Utilities", 430, 1.0)
emitExpense("Shopping", "Expenses:Shopping", 3000, 0.5)
emitExpense("Eat out", "Expenses:Restaurants", 2500, 0.5)
emitExpense("Groceries", "Expenses:Food", 5000, 0.9)
if state.LoanBalance > 0 {
emi := math.Min(state.Balance-10000, 30000.0)
@ -349,12 +372,17 @@ func emitExpense(state *GeneratorState, start time.Time) {
}
if state.Balance < 10000 {
emit("Pay Credit Card Bill", "Liabilities:CreditCard:Freedom", -state.CreditBalance, 1.0)
state.CreditBalance = 0
return
}
if lo.Contains([]time.Month{time.January, time.April, time.November, time.December}, start.Month()) {
emit("Dress", "Expenses:Clothing", 5000, 0.5)
}
emit("Pay Credit Card Bill", "Liabilities:CreditCard:Freedom", -state.CreditBalance, 1.0)
state.CreditBalance = 0
}
func emitInvestment(state *GeneratorState, start time.Time) {
if start.Month() == time.April {
@ -385,7 +413,6 @@ func emitInvestment(state *GeneratorState, start time.Time) {
state.NiftyBalance += units
state.Balance += amount
}
}
func generateJournalFile(cwd string) {

View File

@ -1,10 +1,13 @@
package transaction
import (
"sync"
"time"
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/query"
"github.com/samber/lo"
"gorm.io/gorm"
)
type Transaction struct {
@ -20,6 +23,32 @@ type Transaction struct {
Note string `json:"note"`
}
type transactionCache struct {
sync.Once
transactions map[string]Transaction
}
var tcache transactionCache
func loadTransactionCache(db *gorm.DB) {
postings := query.Init(db).All()
tcache.transactions = make(map[string]Transaction)
for _, t := range Build(postings) {
tcache.transactions[t.ID] = t
}
}
func GetById(db *gorm.DB, id string) (Transaction, bool) {
tcache.Do(func() { loadTransactionCache(db) })
t, found := tcache.transactions[id]
return t, found
}
func ClearCache() {
tcache = transactionCache{}
}
func Build(postings []posting.Posting) []Transaction {
grouped := lo.GroupBy(postings, func(p posting.Posting) string { return p.TransactionID })
return lo.Map(lo.Values(grouped), func(ps []posting.Posting, _ int) Transaction {

View File

@ -0,0 +1,156 @@
package server
import (
"time"
"github.com/ananthakumaran/paisa/internal/accounting"
"github.com/ananthakumaran/paisa/internal/config"
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/model/transaction"
"github.com/ananthakumaran/paisa/internal/query"
"github.com/ananthakumaran/paisa/internal/utils"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"github.com/shopspring/decimal"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type CreditCardSummary struct {
Account string `json:"account"`
Network string `json:"network"`
Number string `json:"number"`
Balance decimal.Decimal `json:"balance"`
Bills []CreditCardBill `json:"bills"`
CreditLimit decimal.Decimal `json:"creditLimit"`
}
type CreditCardBill struct {
StatementStartDate time.Time `json:"statementStartDate"`
StatementEndDate time.Time `json:"statementEndDate"`
DueDate time.Time `json:"dueDate"`
PaidDate *time.Time `json:"paidDate"`
Credits decimal.Decimal `json:"credits"`
Debits decimal.Decimal `json:"debits"`
DebitsRunningBalance decimal.Decimal
OpeningBalance decimal.Decimal `json:"openingBalance"`
ClosingBalance decimal.Decimal `json:"closingBalance"`
Postings []posting.Posting `json:"postings"`
Transactions []transaction.Transaction `json:"transactions"`
}
func GetCreditCards(db *gorm.DB) gin.H {
creditCards := []CreditCardSummary{}
for _, creditCardConfig := range config.GetConfig().CreditCards {
ps := query.Init(db).Where("account = ?", creditCardConfig.Account).All()
creditCards = append(creditCards, buildCreditCard(db, creditCardConfig, ps, false))
}
return gin.H{"creditCards": creditCards}
}
func GetCreditCard(db *gorm.DB, account string) gin.H {
for _, creditCardConfig := range config.GetConfig().CreditCards {
if creditCardConfig.Account == account {
ps := query.Init(db).Where("account = ?", creditCardConfig.Account).All()
creditCard := buildCreditCard(db, creditCardConfig, ps, true)
return gin.H{"creditCard": creditCard, "found": true}
}
}
return gin.H{"found": false}
}
func buildCreditCard(db *gorm.DB, creditCardConfig config.CreditCard, ps []posting.Posting, includePostings bool) CreditCardSummary {
bills := computeBills(db, creditCardConfig, ps, includePostings)
balance := decimal.Zero
if len(bills) > 0 {
balance = bills[len(bills)-1].ClosingBalance
}
return CreditCardSummary{
Account: creditCardConfig.Account,
Network: creditCardConfig.Network,
Number: creditCardConfig.Number,
Balance: balance,
Bills: bills,
CreditLimit: decimal.NewFromInt(int64(creditCardConfig.CreditLimit)),
}
}
func computeBills(db *gorm.DB, creditCardConfig config.CreditCard, ps []posting.Posting, includePostings bool) []CreditCardBill {
bills := []CreditCardBill{}
grouped := accounting.GroupByMonthlyBillingCycle(ps, creditCardConfig.StatementEndDay)
balance := decimal.Zero
creditsRunningBalance := decimal.Zero
debitsRunningBalance := decimal.Zero
unpaidBill := 0
for _, month := range utils.SortedKeys(grouped) {
statementEndDate, err := time.Parse("2006-01", month)
if err != nil {
log.Fatal(err)
}
statementEndDate = statementEndDate.AddDate(0, 0, creditCardConfig.StatementEndDay-1)
statementStartDate := statementEndDate.AddDate(0, -1, 1)
var dueDate time.Time
if creditCardConfig.StatementEndDay < creditCardConfig.DueDay {
dueDate = utils.BeginningOfMonth(statementEndDate).AddDate(0, 0, creditCardConfig.DueDay-1)
} else {
dueDate = utils.BeginningOfMonth(statementEndDate).AddDate(0, 1, creditCardConfig.DueDay-1)
}
bill := CreditCardBill{
StatementStartDate: statementStartDate,
StatementEndDate: statementEndDate,
DueDate: dueDate,
OpeningBalance: balance,
Postings: []posting.Posting{},
Transactions: []transaction.Transaction{},
}
transactionIDs := map[string]bool{}
for _, p := range grouped[month] {
balance = balance.Add(p.Amount.Neg())
if p.Amount.IsPositive() {
creditsRunningBalance = creditsRunningBalance.Add(p.Amount)
bill.Credits = bill.Credits.Add(p.Amount)
for unpaidBill < len(bills) {
if bills[unpaidBill].DebitsRunningBalance.LessThanOrEqual(creditsRunningBalance) {
paidDate := p.Date
bills[unpaidBill].PaidDate = &paidDate
unpaidBill++
} else {
break
}
}
} else {
bill.Debits = bill.Debits.Add(p.Amount.Neg())
debitsRunningBalance = debitsRunningBalance.Add(p.Amount.Neg())
}
if includePostings {
bill.Postings = append(bill.Postings, p)
transactionIDs[p.TransactionID] = true
}
}
bill.DebitsRunningBalance = debitsRunningBalance
bill.ClosingBalance = balance
bill.Transactions = lo.Map(lo.Keys(transactionIDs), func(id string, _ int) transaction.Transaction {
t, _ := transaction.GetById(db, id)
return t
})
accounting.SortTransactionAsc(bill.Transactions)
bills = append(bills, bill)
}
return bills
}

View File

@ -363,6 +363,14 @@ func Build(db *gorm.DB, enableCompression bool) *gin.Engine {
c.JSON(200, goal.GetGoalDetails(db, c.Param("type"), c.Param("name")))
})
router.GET("/api/credit_cards", func(c *gin.Context) {
c.JSON(200, GetCreditCards(db))
})
router.GET("/api/credit_cards/:account", func(c *gin.Context) {
c.JSON(200, GetCreditCard(db, c.Param("account")))
})
router.NoRoute(func(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(web.Index))
})

View File

@ -12,22 +12,6 @@ import (
"gorm.io/gorm"
)
type transactionCache struct {
sync.Once
transactions map[string]transaction.Transaction
}
var tcache transactionCache
func loadTransactionCache(db *gorm.DB) {
postings := query.Init(db).All()
tcache.transactions = make(map[string]transaction.Transaction)
for _, t := range transaction.Build(postings) {
tcache.transactions[t.ID] = t
}
}
type interestCache struct {
sync.Once
postings map[int64][]posting.Posting
@ -76,13 +60,11 @@ func IsCapitalGains(p posting.Posting) bool {
}
func IsStockSplit(db *gorm.DB, p posting.Posting) bool {
tcache.Do(func() { loadTransactionCache(db) })
if utils.IsCurrency(p.Commodity) {
return false
}
t, found := tcache.transactions[p.TransactionID]
t, found := transaction.GetById(db, p.TransactionID)
if !found {
return false
}
@ -96,13 +78,11 @@ func IsStockSplit(db *gorm.DB, p posting.Posting) bool {
}
func IsSellWithCapitalGains(db *gorm.DB, p posting.Posting) bool {
tcache.Do(func() { loadTransactionCache(db) })
if utils.IsCurrency(p.Commodity) {
return false
}
t, found := tcache.transactions[p.TransactionID]
t, found := transaction.GetById(db, p.TransactionID)
if !found {
return false
}

View File

@ -13,17 +13,18 @@ func XIRR(db *gorm.DB, ps []posting.Posting) decimal.Decimal {
today := utils.EndOfToday()
marketAmount := utils.SumBy(ps, func(p posting.Posting) decimal.Decimal {
if IsCapitalGains(p) {
return p.Amount.Neg()
return decimal.Zero
}
return p.MarketAmount
})
cashflows := lo.Reverse(lo.Map(ps, func(p posting.Posting, _ int) xirr.Cashflow {
if IsInterest(db, p) || IsInterestRepayment(db, p) || IsCapitalGains(p) {
if IsInterest(db, p) || IsInterestRepayment(db, p) {
return xirr.Cashflow{Date: p.Date, Amount: 0}
} else {
return xirr.Cashflow{Date: p.Date, Amount: p.Amount.Neg().Round(4).InexactFloat64()}
}
}))
cashflows = append(cashflows, xirr.Cashflow{Date: today, Amount: marketAmount.Round(4).InexactFloat64()})
return xirr.XIRR(cashflows)
}

View File

@ -27,6 +27,7 @@ nav:
- reference/ledger-cli.md
- reference/editor.md
- reference/user-authentication.md
- reference/credit-cards.md
- reference/analysis.md
- 'Tax':
- reference/tax/index.md

View File

@ -1088,3 +1088,36 @@ textarea:invalid {
div.is-hoverable:hover {
background-color: $white-bis;
}
// credit card
.credit-card-container {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fill, minmax(19rem, 25rem));
}
.credit-card {
aspect-ratio: 3.375/2.125;
max-width: 25rem;
min-width: 19rem;
flex: 1;
border-radius: 0.7rem;
display: flex;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3) !important;
background: linear-gradient(
345deg,
$grey-lightest 0%,
$grey-lightest 60%,
$grey-lighter 60%,
$grey-lighter 85%,
$grey-light 85%,
$grey-light 95%,
$grey 95%,
$grey 100%
);
.chip {
color: $amber-700;
}
}

View File

@ -179,4 +179,19 @@ html[data-theme="dark"] {
}
}
}
.credit-card {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 1) !important;
background: linear-gradient(
345deg,
$white 0%,
$white 60%,
$white-bis 60%,
$white-bis 85%,
$white-ter 85%,
$white-ter 95%,
$grey-lightest 95%,
$grey-lightest 100%
);
}
}

View File

@ -313,6 +313,7 @@ const COLORS = {
primary: MaterialUI.deeppurple.a100,
secondary: MaterialUI.lightblue.a400,
tertiary: MaterialUI.amber.a400,
neutral: "hsl(0, 0%, 48%)",
assets: MaterialUI.lightblue.a400,
expenses: MaterialUI.red.a400,
income: MaterialUI.lime.a700,

View File

@ -0,0 +1,82 @@
<script lang="ts">
import { iconText } from "$lib/icon";
import {
formatCurrency,
formatPercentage,
restName,
type CreditCardSummary,
now,
type CreditCardBill
} from "$lib/utils";
import _ from "lodash";
import CreditCardNetwork from "./CreditCardNetwork.svelte";
import DueDate from "./DueDate.svelte";
export let creditCard: CreditCardSummary;
function lastBill(creditCard: CreditCardSummary): CreditCardBill {
return _.find(_.reverse(_.clone(creditCard.bills)), (b) => {
return b.statementEndDate.isSameOrBefore(now());
});
}
$: bill = lastBill(creditCard);
</script>
<div class="credit-card box p-3 m-0 flex-col justify-between">
<div class="is-flex justify-between has-text-weight-bold is-size-5">
<div style="margin: 35px 0 0 15px;" class="opacity-20 chip">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"
><path
fill="currentColor"
d="M10 4h10c1.11 0 2 .89 2 2v2h-3.41L16 10.59v4l-2 2V20h-4v-3.41l-2-2V9.41l2-2zm8 7.41V14h4v-4h-2.59zM6.59 8L8 6.59V4H4c-1.11 0-2 .89-2 2v2zM6 14v-4H2v4zm2 3.41L6.59 16H2v2c0 1.11.89 2 2 2h4zM17.41 16L16 17.41V20h4c1.11 0 2-.89 2-2v-2z"
/></svg
>
</div>
<div>
<a
class="secondary-link has-text-grey"
href="/liabilities/credit_cards/{encodeURIComponent(creditCard.account)}"
>
<span class="custom-icon">{iconText(creditCard.account)}</span>
<span class="ml-1">{restName(restName(creditCard.account))}</span>
</a>
</div>
</div>
<div class="flex justify-between">
<div class="flex flex-col">
{#if bill}
<div class="is-size-7">
<span class="has-text-grey">Amount Due</span>
</div>
<div>
<span class="is-size-4 has-text-grey-dark">{formatCurrency(bill.closingBalance)}</span>
</div>
<div class="is-size-7 has-text-grey">
<DueDate dueDate={bill.dueDate} paidDate={bill.paidDate} />
</div>
{/if}
</div>
<div class="flex flex-col">
<div class="is-size-7">
<span class="has-text-grey">Balance</span>
</div>
<div class="flex flex-col">
<span class="is-size-4 has-text-grey-dark">{formatCurrency(creditCard.balance)}</span>
<span class="is-size-7 has-text-grey"
>{formatPercentage(creditCard.balance / creditCard.creditLimit)} of {formatCurrency(
creditCard.creditLimit
)}
</span>
</div>
</div>
</div>
<div class="is-flex justify-between items-end">
<div class="opacity-25 has-text-weight-bold is-size-5">
* * * * &nbsp; {creditCard.number}
</div>
<div class="opacity-15">
<CreditCardNetwork size={48} name={creditCard.network} />
</div>
</div>
</div>

View File

@ -0,0 +1,119 @@
<script lang="ts">
export let size: number;
export let name: string;
$: multiplier =
{
rupay: 1.3,
discover: 1.7
}[name] || 1;
</script>
{#if name == "visa"}
<div style="margin: -{size / 2.9}px 0">
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24"
><path
fill="currentColor"
d="M9.112 8.262L5.97 15.758H3.92L2.374 9.775c-.094-.368-.175-.503-.461-.658C1.447 8.864.677 8.627 0 8.479l.046-.217h3.3a.904.904 0 0 1 .894.764l.817 4.338l2.018-5.102zm8.033 5.049c.008-1.979-2.736-2.088-2.717-2.972c.006-.269.262-.555.822-.628a3.66 3.66 0 0 1 1.913.336l.34-1.59a5.207 5.207 0 0 0-1.814-.333c-1.917 0-3.266 1.02-3.278 2.479c-.012 1.079.963 1.68 1.698 2.04c.756.367 1.01.603 1.006.931c-.005.504-.602.725-1.16.734c-.975.015-1.54-.263-1.992-.473l-.351 1.642c.453.208 1.289.39 2.156.398c2.037 0 3.37-1.006 3.377-2.564m5.061 2.447H24l-1.565-7.496h-1.656a.883.883 0 0 0-.826.55l-2.909 6.946h2.036l.405-1.12h2.488zm-2.163-2.656l1.02-2.815l.588 2.815zm-8.16-4.84l-1.603 7.496H8.34l1.605-7.496z"
/></svg
>
</div>
{/if}
{#if name == "mastercard"}
<div style="margin: -{size / 2.8}px 0">
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24"
><path
fill="currentColor"
d="M15.273 18.728A6.728 6.728 0 1 1 22 11.999V12a6.735 6.735 0 0 1-6.727 6.728"
opacity=".5"
/><path
fill="currentColor"
d="M8.727 18.728A6.728 6.728 0 1 1 15.455 12a6.735 6.735 0 0 1-6.728 6.728"
/></svg
>
</div>
{/if}
{#if name == "dinersclub"}
<div style="margin: -{size / 6}px 0">
<svg
xmlns="http://www.w3.org/2000/svg"
width={size * 0.65}
height={size * 0.65}
viewBox="0 0 24 24"
><path
fill="currentColor"
d="M16.506 11.982a6.026 6.026 0 0 0-3.866-5.618V17.6a6.025 6.025 0 0 0 3.866-5.618M8.33 17.598V6.365a6.03 6.03 0 0 0-3.863 5.617a6.028 6.028 0 0 0 3.863 5.616m2.156-15.113A9.497 9.497 0 0 0 .99 11.982a9.495 9.495 0 0 0 9.495 9.494a9.495 9.495 0 0 0 9.496-9.494a9.499 9.499 0 0 0-9.496-9.497Zm-.023 19.888C4.723 22.4 0 17.75 0 12.09C0 5.905 4.723 1.626 10.463 1.627h2.69C18.822 1.627 24 5.903 24 12.09c0 5.658-5.176 10.283-10.848 10.283"
/></svg
>
</div>
{/if}
{#if name == "amex"}
<div style="margin: -{size / 2.1}px 0">
<svg
xmlns="http://www.w3.org/2000/svg"
width={size * 1.2}
height={size * 1.2}
viewBox="0 0 24 24"
><path
fill="currentColor"
fill-rule="evenodd"
d="m4.314 11.965l-.82-1.997l-.815 1.997zm7.859 2.161l-.005-3.922l-1.736 3.922h-1.05L7.64 10.2v3.926H5.206l-.46-1.117H2.253l-.465 1.117h-1.3l2.144-5.008H4.41l2.036 4.742V9.118H8.4l1.567 3.397l1.439-3.397H13.4v5.008zm3.133-1.024v-.997h2.628v-1.022h-2.628v-.911h3.001l1.31 1.46l-1.368 1.47zm8.111 1.044h-1.556l-1.474-1.659l-1.532 1.659h-4.742v-5.01h4.815l1.473 1.642l1.523-1.642h1.564l-2.327 2.505z"
/></svg
>
</div>
{/if}
{#if name == "rupay"}
<div style="margin: -{size / 1.9}px -{size * 1.7}px">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 333334 199007"
width={size * 3.8 * multiplier}
height={size * multiplier}
shape-rendering="geometricPrecision"
text-rendering="geometricPrecision"
image-rendering="optimizeQuality"
fill-rule="evenodd"
clip-rule="evenodd"
><path
d="M214088 83928h13199v20970l11418-20970h12113l-24422 42395s-2267 3556-5079 5437c-2310 1547-5151 1477-6019 1540-4824-42-10643-55-10643-55l2807-10106 4542-8s2079-212 2882-1237c765-977 1156-1954 1156-3387 0-2148-1954-34580-1954-34580zM76939 88116c-1837 4256-7533 3772-7533 3772l-6632-31 2421-9013s5933 22 8843 22c3115 0 4088 2502 2902 5249zm15073-6142c1129-8943-6741-11201-15250-11201H54402l-13199 48105h14208l4436-16333 7970 65s3289-191 3354 2898c69 3295-2442 9345-2280 13370h14596l-32-1281s-1213-322-1078-2026c56-709 839-2953 1864-5979 618-1334 1550-4499 1464-7076-107-3217-2126-4710-5037-5754 9074-2128 11342-14787 11343-14787zm3224 1954h12959l-5337 20533s-1331 4579 2952 4932c3384 280 5902-3758 6727-6493 1084-3592 5296-18973 5296-18973h13351l-10159 34950h-11657l1432-4993s-5947 7252-14783 6382c-7854-771-8531-6468-7170-13575 668-3489 6389-22763 6389-22763zm66557 4298c-1849 5158-6944 4550-6944 4550l-6956 2 2746-10220s4403 23 7311 23c3560 0 4852 2828 3843 5644zm14609-4776c1130-8944-5687-12678-14197-12678h-22359l-13198 48105h14208l3995-14831 11320 69s17530 742 20232-20665zm11945 28307c-2220 563-4912 869-5446-1148-1466-5527 11474-7145 11474-7145 88 5036-4325 7859-6029 8293zm19575-10116c1707-5814 3865-11319 2128-14370-2660-4670-7468-5080-14502-5080-7771 0-17365 1476-20492 11810h12937s1179-3892 6035-3647c4298 217 4063 3174 2479 4809-2778 2865-10450 1276-18947 4224-7424 2576-10022 12338-8409 16234 1563 3778 4474 4259 8401 4645 6307 620 11143-2897 13394-4961 0 2292 60 3572 60 3572h13614l-33-1281s-1213-322-1078-2026c98-1248 2450-7252 4412-13929z"
fill="currentColor"
/><path fill="currentColor" d="M267751 75852l-15239 53011 28524-26506z" /><path
fill="currentColor"
d="M257982 75852l-15239 53011 28525-26506z"
/></svg
>
</div>
{/if}
{#if name == "jcb"}
<div style="margin: -{size / 2.6}px 0">
<svg
xmlns="http://www.w3.org/2000/svg"
width={size * multiplier}
height={size * multiplier}
viewBox="0 0 24 24"
><path
fill="currentColor"
d="M13.05 9.864c.972.074 1.726.367 2.355.685v-1.31s-1.258-.317-2.441-.368C8.838 8.686 7.669 10.305 7.669 12s1.17 3.314 5.295 3.13c1.183-.054 2.44-.37 2.44-.37v-1.309c-.619.308-1.382.611-2.354.683c-1.68.128-2.69-.69-2.69-2.134c0-1.445 1.01-2.261 2.69-2.135m7.685 4.122a1.48 1.48 0 0 1-.215.02h-1.8v-1.631h1.8c.057 0 .164.01.215.02a.806.806 0 0 1 .632.795a.804.804 0 0 1-.632.796M18.72 9.95h1.632c.059 0 .145.007.177.013a.736.736 0 0 1 .626.74a.735.735 0 0 1-.626.739a1.571 1.571 0 0 1-.178.013h-1.63zm3.499 1.985V11.9c.913-.133 1.415-.726 1.415-1.42c0-.883-.734-1.392-1.73-1.442c-.077-.003-.202-.01-.304-.01h-5.332v5.946h5.755c1.13 0 1.977-.604 1.977-1.547c0-.87-.772-1.422-1.781-1.491zm-17.864.68c0 .878-.591 1.53-1.666 1.53c-.917 0-1.817-.272-2.689-.694v1.309s1.402.383 3.191.383c2.971 0 3.837-1.125 3.837-2.529V9.027H4.354z"
/></svg
>
</div>
{/if}
{#if name == "discover"}
<div style="margin: -{size / 1.4}px 0">
<svg
xmlns="http://www.w3.org/2000/svg"
width={size * multiplier}
height={size * multiplier}
viewBox="0 0 24 24"
><path
fill="currentColor"
d="M14.58 12a2.023 2.023 0 1 1-2.025-2.023h.002c1.118 0 2.023.906 2.023 2.023m-5.2-2.001c-1.124 0-2.025.884-2.025 1.99c0 1.118.878 1.984 2.007 1.984c.319 0 .593-.063.93-.221v-.873c-.296.297-.559.416-.895.416c-.747 0-1.277-.542-1.277-1.312c0-.73.547-1.306 1.243-1.306c.354 0 .622.126.93.428v-.873a1.898 1.898 0 0 0-.913-.233m-3.352 1.545c-.445-.165-.576-.273-.576-.479c0-.239.233-.422.553-.422c.222 0 .405.091.598.308l.388-.508a1.665 1.665 0 0 0-1.117-.422c-.673 0-1.186.467-1.186 1.089c0 .524.239.792.936 1.043c.291.103.438.171.513.217a.456.456 0 0 1 .222.394c0 .308-.245.536-.576.536c-.354 0-.639-.177-.809-.507l-.479.461c.342.502.752.724 1.317.724c.771 0 1.311-.513 1.311-1.249c-.002-.603-.252-.876-1.095-1.185M24 10.3a.29.29 0 0 1-.288.291a.29.29 0 0 1-.291-.291v-.003A.29.29 0 1 1 24 10.3m-.059.001a.235.235 0 0 0-.231-.239a.234.234 0 0 0-.232.239c0 .132.104.239.232.239a.235.235 0 0 0 .231-.239M3.472 13.887h.742v-3.803h-.742zm12.702-1.248l-1.014-2.554h-.81l1.614 3.9h.399l1.643-3.9h-.804zm2.166 1.248h2.104v-.644h-1.362v-1.027h1.312v-.644h-1.312v-.844h1.362v-.644H18.34zm5.409-3.557l.11.138h-.097l-.094-.13v.13h-.08v-.334h.107c.081 0 .126.036.126.103c.001.046-.025.08-.072.093m-.006-.092c0-.029-.021-.043-.06-.043h-.014v.087h.014c.039 0 .06-.014.06-.044m-1.228 2.047l1.197 1.602H22.8l-1.027-1.528h-.097v1.528h-.741v-3.803h1.1c.855 0 1.346.411 1.346 1.123c0 .583-.308.965-.866 1.078m.103-1.038c0-.37-.251-.563-.713-.563h-.228v1.152h.217c.473-.001.724-.207.724-.589m-19.487.742a1.91 1.91 0 0 1-.69 1.46c-.365.303-.781.439-1.357.439H.001v-3.803H1.09c1.202 0 2.041.781 2.041 1.904m-.764-.006c0-.364-.154-.718-.411-.947c-.245-.222-.536-.308-1.015-.308H.742v2.515h.199c.479 0 .782-.092 1.015-.302c.256-.228.411-.593.411-.958"
/></svg
>
</div>
{/if}

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { dueDateIcon } from "$lib/utils";
import dayjs from "dayjs";
export let dueDate: dayjs.Dayjs;
export let paidDate: dayjs.Dayjs;
$: icon = dueDateIcon(dueDate, paidDate);
</script>
<span title="due on {dueDate.format('DD MMM YYYY')}">
<span class="icon is-small {icon.color}">
<i class="fas {icon.icon}" />
</span>
{#if paidDate}
<span>paid on {paidDate.format("DD MMM YYYY")}</span>
{:else}
<span>due {dueDate.fromNow()}</span>
{/if}
</span>

View File

@ -97,6 +97,7 @@
href: "/liabilities",
children: [
{ label: "Balance", href: "/balance" },
{ label: "Credit Cards", href: "/credit_cards", help: "credit-cards" },
{ label: "Repayment", href: "/repayment" },
{ label: "Interest", href: "/interest" }
]

View File

@ -5,7 +5,8 @@ import {
transactionTotal,
type Transaction,
type TransactionSchedule,
type TransactionSequence
type TransactionSequence,
dueDateIcon
} from "./utils";
import dayjs from "dayjs";
import { parse, type CronExprs } from "@datasert/cronjs-parser";
@ -148,32 +149,7 @@ export function nextUnpaidSchedule(ts: TransactionSequence) {
}
export function scheduleIcon(schedule: TransactionSchedule) {
let icon = "fa-circle-check";
let glyph = iconGlyph("fa6-solid:circle-check");
let color = "has-text-success";
let svgColor = "svg-text-success";
if (!schedule.actual) {
if (schedule.scheduled.isBefore(now(), "day")) {
color = "has-text-danger";
icon = "fa-exclamation-triangle";
glyph = iconGlyph("fa6-solid:triangle-exclamation");
svgColor = "svg-text-danger";
} else {
color = "has-text-grey";
svgColor = "svg-text-grey";
}
} else {
if (schedule.actual.isSameOrBefore(schedule.scheduled, "day")) {
color = "has-text-success";
svgColor = "svg-text-success";
} else {
color = "has-text-warning-dark";
svgColor = "svg-text-warning-dark";
}
}
return { icon, color, svgColor, glyph };
return dueDateIcon(schedule.scheduled, schedule.actual);
}
export function intervalText(ts: TransactionSequence) {

View File

@ -9,6 +9,7 @@ import { obscure } from "../persisted_store";
import { error } from "@sveltejs/kit";
import { goto } from "$app/navigation";
import chroma from "chroma-js";
import { iconGlyph } from "./icon";
export interface AutoCompleteItem {
label: string;
@ -481,6 +482,28 @@ export interface Log {
msg: string;
}
export interface CreditCardBill {
openingBalance: number;
closingBalance: number;
debits: number;
credits: number;
statementStartDate: dayjs.Dayjs;
statementEndDate: dayjs.Dayjs;
dueDate: dayjs.Dayjs;
paidDate: dayjs.Dayjs;
postings: Posting[];
transactions: Transaction[];
}
export interface CreditCardSummary {
account: string;
network: string;
number: string;
balance: number;
bills: CreditCardBill[];
creditLimit: number;
}
export interface GoalSummary {
type: string;
name: string;
@ -609,6 +632,14 @@ export function ajax(route: "/api/liabilities/interest"): Promise<{
interest_timeline_breakdown: Interest[];
}>;
export function ajax(route: "/api/credit_cards"): Promise<{ creditCards: CreditCardSummary[] }>;
export function ajax(
route: "/api/credit_cards/:account",
options?: RequestOptions,
params?: Record<string, string>
): Promise<{ creditCard: CreditCardSummary; found: boolean }>;
export function ajax(route: "/api/goals"): Promise<{ goals: GoalSummary[] }>;
export function ajax(
route: "/api/goals/retirement/:name",
@ -764,7 +795,7 @@ export async function ajax(
return JSON.parse(body, (key, value) => {
if (
_.isString(value) &&
/date|time|now/.test(key) &&
/Date|date|time|now/.test(key) &&
/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/.test(
value
)
@ -1206,3 +1237,32 @@ export function asTransaction(p: Posting): Transaction {
export function svgUrl(identifier: string) {
return `url(${new URL("#" + identifier, window.location.toString())})`;
}
export function dueDateIcon(dueDate: dayjs.Dayjs, clearedDate: dayjs.Dayjs) {
let icon = "fa-circle-check";
let glyph = iconGlyph("fa6-solid:circle-check");
let color = "has-text-success";
let svgColor = "svg-text-success";
if (!clearedDate) {
if (dueDate.isBefore(now(), "day")) {
color = "has-text-danger";
icon = "fa-exclamation-triangle";
glyph = iconGlyph("fa6-solid:triangle-exclamation");
svgColor = "svg-text-danger";
} else {
color = "has-text-grey";
svgColor = "svg-text-grey";
}
} else {
if (clearedDate.isSameOrBefore(dueDate, "day")) {
color = "has-text-success";
svgColor = "svg-text-success";
} else {
color = "has-text-warning-dark";
svgColor = "svg-text-warning-dark";
}
}
return { icon, color, svgColor, glyph };
}

View File

@ -0,0 +1,39 @@
<script lang="ts">
import CreditCardCard from "$lib/components/CreditCardCard.svelte";
import ZeroState from "$lib/components/ZeroState.svelte";
import { ajax, helpUrl, type CreditCardSummary } from "$lib/utils";
import _ from "lodash";
import { onMount } from "svelte";
let isEmpty = false;
let creditCards: CreditCardSummary[] = [];
onMount(async () => {
({ creditCards } = await ajax("/api/credit_cards"));
if (_.isEmpty(creditCards)) {
isEmpty = true;
}
});
</script>
<section class="section">
<div class="container is-fluid">
<div class="columns flex-wrap">
<div class="column is-12">
<div class="credit-card-container">
{#each creditCards as creditCard}
<CreditCardCard {creditCard} />
{/each}
</div>
</div>
</div>
<div class="columns flex-wrap">
<div class="column is-12">
<ZeroState item={!isEmpty}>
<strong>Oops!</strong> You haven't configured any credit cards yet. Checkout the
<a href={helpUrl("credit-card")}>docs</a> page to get started.
</ZeroState>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,181 @@
<script lang="ts">
import CreditCardCard from "$lib/components/CreditCardCard.svelte";
import {
ajax,
formatCurrency,
type CreditCardBill,
type CreditCardSummary,
formatPercentage
} from "$lib/utils";
import _, { now } from "lodash";
import { onMount } from "svelte";
import type { PageData } from "./$types";
import { redirect } from "@sveltejs/kit";
import { MasonryGrid } from "@egjs/svelte-grid";
import TransactionCard from "$lib/components/TransactionCard.svelte";
import LevelItem from "$lib/components/LevelItem.svelte";
import COLORS from "$lib/colors";
import { iconify } from "$lib/icon";
import DueDate from "$lib/components/DueDate.svelte";
let UntypedMasonryGrid = MasonryGrid as any;
export let data: PageData;
let creditCard: CreditCardSummary;
let currentBill: CreditCardBill;
let found = false;
let small = true;
function lastBill(creditCard: CreditCardSummary): CreditCardBill {
return _.find(_.reverse(_.clone(creditCard.bills)), (b) => {
return b.statementEndDate.isSameOrBefore(now());
});
}
onMount(async () => {
({ creditCard, found } = await ajax("/api/credit_cards/:account", null, data));
currentBill = lastBill(creditCard);
if (!found) {
redirect(307, `/liabilities/credit_cards`);
}
});
</script>
<section class="section">
<div class="container is-fluid">
<div class="columns flex-wrap">
<div class="column is-3-widescreen is-4">
{#if creditCard}
<div class="flex mb-4">
<CreditCardCard {creditCard} />
</div>
<nav class="level grid-2">
<LevelItem
narrow
small
title="Available Credit"
color={COLORS.neutral}
value={formatCurrency(Math.max(creditCard.creditLimit - creditCard.balance, 0))}
/>
<LevelItem
narrow
small
title="Credit Usage"
color={COLORS.neutral}
value={formatPercentage(creditCard.balance / creditCard.creditLimit, 2)}
/>
</nav>
<nav class="level grid-2">
<LevelItem
narrow
small
title="Statement Count"
color={COLORS.neutral}
value={creditCard.bills.length.toString()}
/>
<LevelItem
narrow
small
title="Transaction Count"
color={COLORS.neutral}
value={_.sumBy(creditCard.bills, (b) => b.transactions.length).toString()}
/>
</nav>
{/if}
</div>
<div class="column is-9-widescreen is-8">
{#if currentBill}
<div class="flex flex-wrap gap-4 mb-4">
<div class="box py-2 m-0 flex-grow" style="border: 1px solid transparent">
<div
class="is-flex mr-2 is-align-items-baseline overflow-x-scroll"
style="min-width: fit-content"
>
<div class="ml-3 custom-icon is-size-5">
<span>{iconify(creditCard.account)}</span>
</div>
<div class="ml-3">
<span class="mr-1 is-size-7 has-text-grey">Payment</span>
<span
><DueDate dueDate={currentBill.dueDate} paidDate={currentBill.paidDate} /></span
>
</div>
</div>
</div>
<div class="has-text-right">
<div class="select is-medium">
<select bind:value={currentBill}>
{#each _.reverse(_.clone(creditCard.bills)) as bill}
<option value={bill}
>{bill.statementStartDate.format("DD MMM YYYY")}{bill.statementEndDate.format(
"DD MMM YYYY"
)}</option
>
{/each}
</select>
</div>
</div>
</div>
<nav class="level flex gap-4 overflow-x-scroll" style="justify-content: start;">
<LevelItem
{small}
narrow
title="Opening Balance"
color={COLORS.neutral}
value={formatCurrency(currentBill.openingBalance)}
/>
<div class="level-item is-narrow">
<span class="icon is-size-3">
<i class="fas fa-plus" />
</span>
</div>
<LevelItem
{small}
narrow
title="Debits"
color={COLORS.expenses}
value={formatCurrency(currentBill.debits)}
/>
<div class="level-item is-narrow">
<span class="icon is-size-3">
<i class="fas fa-minus" />
</span>
</div>
<LevelItem
{small}
narrow
title="Credits"
color={COLORS.income}
value={formatCurrency(currentBill.credits)}
/>
<div class="level-item is-narrow">
<span class="icon is-size-3">
<i class="fas fa-equals" />
</span>
</div>
<LevelItem
{small}
narrow
title="Amount Due"
color={COLORS.liabilities}
value={formatCurrency(currentBill.closingBalance)}
/>
</nav>
<div>
<UntypedMasonryGrid gap={10} maxStretchColumnSize={500} align="stretch">
{#each currentBill.transactions as t}
<div class="mr-3 is-flex-grow-1">
<TransactionCard {t} />
</div>
{/each}
</UntypedMasonryGrid>
</div>
{/if}
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,7 @@
import type { PageLoad } from "./$types";
export const load = (async ({ params }) => {
return {
account: params.slug
};
}) satisfies PageLoad;

View File

@ -63,14 +63,6 @@
<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>
</div>
<div
class="columns flex-wrap"
use:dndzone={{ items: goals, dropTargetStyle: {}, flipDurationMs: 300 }}
@ -83,5 +75,13 @@
</div>
{/each}
</div>
<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>
</div>
</div>
</section>

View File

@ -28,7 +28,8 @@
"retirement": [],
"savings": []
},
"user_accounts": []
"user_accounts": [],
"credit_cards": []
},
"now": "2022-02-07T00:00:00Z",
"schema": {
@ -53,7 +54,7 @@
"ui:widget": "icon"
},
"name": {
"description": "name of the account",
"description": "Name of the account",
"minLength": 1,
"type": "string"
}
@ -157,7 +158,7 @@
"type": "integer"
},
"name": {
"description": "name of the commodity",
"description": "Name of the commodity",
"type": "string"
},
"price": {
@ -221,6 +222,76 @@
],
"type": "array"
},
"credit_cards": {
"default": [
{
"account": "Liabilities:CreditCard:Chase",
"credit_limit": 100000,
"due_day": 15,
"statement_end_day": 28
}
],
"items": {
"additionalProperties": false,
"properties": {
"account": {
"description": "Name of the credit card account",
"type": "string"
},
"credit_limit": {
"description": "Credit limit of the card",
"minimum": 1,
"type": "number"
},
"due_day": {
"description": "Due day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"network": {
"description": "Network of the card",
"enum": [
"visa",
"mastercard",
"dinersclub",
"amex",
"rupay",
"jcb",
"discover"
],
"type": "string"
},
"number": {
"description": "Last 4 digits of the card number",
"maxLength": 4,
"minLength": 4,
"pattern": "^[0-9]{4}$",
"type": "string"
},
"statement_end_day": {
"description": "Statement end day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
}
},
"required": [
"account",
"credit_limit",
"statement_end_day",
"due_day",
"network",
"number"
],
"type": "object",
"ui:header": "account"
},
"itemsUniqueProperties": [
"account"
],
"type": "array"
},
"db_path": {
"description": "Path to your database file. It can be absolute or relative to the configuration file. The database file will be created if it does not exist.",
"type": "string"
@ -430,7 +501,7 @@
"ui:widget": "textarea"
},
"name": {
"description": "name of the template",
"description": "Name of the template",
"minLength": 1,
"type": "string"
}

View File

@ -28,7 +28,8 @@
"retirement": [],
"savings": []
},
"user_accounts": []
"user_accounts": [],
"credit_cards": []
},
"now": "2022-02-07T00:00:00Z",
"schema": {
@ -53,7 +54,7 @@
"ui:widget": "icon"
},
"name": {
"description": "name of the account",
"description": "Name of the account",
"minLength": 1,
"type": "string"
}
@ -157,7 +158,7 @@
"type": "integer"
},
"name": {
"description": "name of the commodity",
"description": "Name of the commodity",
"type": "string"
},
"price": {
@ -221,6 +222,76 @@
],
"type": "array"
},
"credit_cards": {
"default": [
{
"account": "Liabilities:CreditCard:Chase",
"credit_limit": 100000,
"due_day": 15,
"statement_end_day": 28
}
],
"items": {
"additionalProperties": false,
"properties": {
"account": {
"description": "Name of the credit card account",
"type": "string"
},
"credit_limit": {
"description": "Credit limit of the card",
"minimum": 1,
"type": "number"
},
"due_day": {
"description": "Due day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"network": {
"description": "Network of the card",
"enum": [
"visa",
"mastercard",
"dinersclub",
"amex",
"rupay",
"jcb",
"discover"
],
"type": "string"
},
"number": {
"description": "Last 4 digits of the card number",
"maxLength": 4,
"minLength": 4,
"pattern": "^[0-9]{4}$",
"type": "string"
},
"statement_end_day": {
"description": "Statement end day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
}
},
"required": [
"account",
"credit_limit",
"statement_end_day",
"due_day",
"network",
"number"
],
"type": "object",
"ui:header": "account"
},
"itemsUniqueProperties": [
"account"
],
"type": "array"
},
"db_path": {
"description": "Path to your database file. It can be absolute or relative to the configuration file. The database file will be created if it does not exist.",
"type": "string"
@ -430,7 +501,7 @@
"ui:widget": "textarea"
},
"name": {
"description": "name of the template",
"description": "Name of the template",
"minLength": 1,
"type": "string"
}

View File

@ -73,7 +73,7 @@
"marketAmount": 0,
"balanceUnits": -100,
"latestPrice": 0,
"xirr": 149.2,
"xirr": 167.56,
"gainAmount": 27,
"absoluteReturn": 0.0027
},

View File

@ -36,7 +36,8 @@
"retirement": [],
"savings": []
},
"user_accounts": []
"user_accounts": [],
"credit_cards": []
},
"now": "2022-02-07T00:00:00Z",
"schema": {
@ -61,7 +62,7 @@
"ui:widget": "icon"
},
"name": {
"description": "name of the account",
"description": "Name of the account",
"minLength": 1,
"type": "string"
}
@ -165,7 +166,7 @@
"type": "integer"
},
"name": {
"description": "name of the commodity",
"description": "Name of the commodity",
"type": "string"
},
"price": {
@ -229,6 +230,76 @@
],
"type": "array"
},
"credit_cards": {
"default": [
{
"account": "Liabilities:CreditCard:Chase",
"credit_limit": 100000,
"due_day": 15,
"statement_end_day": 28
}
],
"items": {
"additionalProperties": false,
"properties": {
"account": {
"description": "Name of the credit card account",
"type": "string"
},
"credit_limit": {
"description": "Credit limit of the card",
"minimum": 1,
"type": "number"
},
"due_day": {
"description": "Due day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"network": {
"description": "Network of the card",
"enum": [
"visa",
"mastercard",
"dinersclub",
"amex",
"rupay",
"jcb",
"discover"
],
"type": "string"
},
"number": {
"description": "Last 4 digits of the card number",
"maxLength": 4,
"minLength": 4,
"pattern": "^[0-9]{4}$",
"type": "string"
},
"statement_end_day": {
"description": "Statement end day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
}
},
"required": [
"account",
"credit_limit",
"statement_end_day",
"due_day",
"network",
"number"
],
"type": "object",
"ui:header": "account"
},
"itemsUniqueProperties": [
"account"
],
"type": "array"
},
"db_path": {
"description": "Path to your database file. It can be absolute or relative to the configuration file. The database file will be created if it does not exist.",
"type": "string"
@ -438,7 +509,7 @@
"ui:widget": "textarea"
},
"name": {
"description": "name of the template",
"description": "Name of the template",
"minLength": 1,
"type": "string"
}

View File

@ -83,7 +83,7 @@
"balanceUnits": 0,
"netInvestmentAmount": -27
},
"xirr": 149.2,
"xirr": 167.56,
"postings": [
{
"id": 11,

View File

@ -51,7 +51,7 @@
"marketAmount": 0,
"balanceUnits": -100,
"latestPrice": 0,
"xirr": 151.56,
"xirr": 170.5,
"gainAmount": 27.3,
"absoluteReturn": 0.00273
},

View File

@ -35,7 +35,8 @@
"retirement": [],
"savings": []
},
"user_accounts": []
"user_accounts": [],
"credit_cards": []
},
"now": "2022-02-07T00:00:00Z",
"schema": {
@ -60,7 +61,7 @@
"ui:widget": "icon"
},
"name": {
"description": "name of the account",
"description": "Name of the account",
"minLength": 1,
"type": "string"
}
@ -164,7 +165,7 @@
"type": "integer"
},
"name": {
"description": "name of the commodity",
"description": "Name of the commodity",
"type": "string"
},
"price": {
@ -228,6 +229,76 @@
],
"type": "array"
},
"credit_cards": {
"default": [
{
"account": "Liabilities:CreditCard:Chase",
"credit_limit": 100000,
"due_day": 15,
"statement_end_day": 28
}
],
"items": {
"additionalProperties": false,
"properties": {
"account": {
"description": "Name of the credit card account",
"type": "string"
},
"credit_limit": {
"description": "Credit limit of the card",
"minimum": 1,
"type": "number"
},
"due_day": {
"description": "Due day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"network": {
"description": "Network of the card",
"enum": [
"visa",
"mastercard",
"dinersclub",
"amex",
"rupay",
"jcb",
"discover"
],
"type": "string"
},
"number": {
"description": "Last 4 digits of the card number",
"maxLength": 4,
"minLength": 4,
"pattern": "^[0-9]{4}$",
"type": "string"
},
"statement_end_day": {
"description": "Statement end day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
}
},
"required": [
"account",
"credit_limit",
"statement_end_day",
"due_day",
"network",
"number"
],
"type": "object",
"ui:header": "account"
},
"itemsUniqueProperties": [
"account"
],
"type": "array"
},
"db_path": {
"description": "Path to your database file. It can be absolute or relative to the configuration file. The database file will be created if it does not exist.",
"type": "string"
@ -437,7 +508,7 @@
"ui:widget": "textarea"
},
"name": {
"description": "name of the template",
"description": "Name of the template",
"minLength": 1,
"type": "string"
}

View File

@ -47,7 +47,7 @@
"balanceUnits": 0,
"netInvestmentAmount": -27.3
},
"xirr": 151.56,
"xirr": 170.5,
"postings": [
{
"id": 15,

View File

@ -51,7 +51,7 @@
"marketAmount": 0,
"balanceUnits": -100,
"latestPrice": 0,
"xirr": 151.56,
"xirr": 170.5,
"gainAmount": 27.3,
"absoluteReturn": 0.00273
},

View File

@ -35,7 +35,8 @@
"retirement": [],
"savings": []
},
"user_accounts": []
"user_accounts": [],
"credit_cards": []
},
"now": "2022-02-07T00:00:00Z",
"schema": {
@ -60,7 +61,7 @@
"ui:widget": "icon"
},
"name": {
"description": "name of the account",
"description": "Name of the account",
"minLength": 1,
"type": "string"
}
@ -164,7 +165,7 @@
"type": "integer"
},
"name": {
"description": "name of the commodity",
"description": "Name of the commodity",
"type": "string"
},
"price": {
@ -228,6 +229,76 @@
],
"type": "array"
},
"credit_cards": {
"default": [
{
"account": "Liabilities:CreditCard:Chase",
"credit_limit": 100000,
"due_day": 15,
"statement_end_day": 28
}
],
"items": {
"additionalProperties": false,
"properties": {
"account": {
"description": "Name of the credit card account",
"type": "string"
},
"credit_limit": {
"description": "Credit limit of the card",
"minimum": 1,
"type": "number"
},
"due_day": {
"description": "Due day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"network": {
"description": "Network of the card",
"enum": [
"visa",
"mastercard",
"dinersclub",
"amex",
"rupay",
"jcb",
"discover"
],
"type": "string"
},
"number": {
"description": "Last 4 digits of the card number",
"maxLength": 4,
"minLength": 4,
"pattern": "^[0-9]{4}$",
"type": "string"
},
"statement_end_day": {
"description": "Statement end day of the card",
"maximum": 31,
"minimum": 1,
"type": "integer"
}
},
"required": [
"account",
"credit_limit",
"statement_end_day",
"due_day",
"network",
"number"
],
"type": "object",
"ui:header": "account"
},
"itemsUniqueProperties": [
"account"
],
"type": "array"
},
"db_path": {
"description": "Path to your database file. It can be absolute or relative to the configuration file. The database file will be created if it does not exist.",
"type": "string"
@ -437,7 +508,7 @@
"ui:widget": "textarea"
},
"name": {
"description": "name of the template",
"description": "Name of the template",
"minLength": 1,
"type": "string"
}

View File

@ -47,7 +47,7 @@
"balanceUnits": 0,
"netInvestmentAmount": -27.3
},
"xirr": 151.56,
"xirr": 170.5,
"postings": [
{
"id": 11,