diff --git a/docs/reference/config.md b/docs/reference/config.md index 68424838..19d42d53 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -285,4 +285,6 @@ credit_cards: # Required, the network of the card number: "0007" # Required, the last 4 digits of the card number + expiration_date: "2029-05-01" + # Required, the expiration date of the card ``` diff --git a/docs/reference/credit-cards.md b/docs/reference/credit-cards.md index ca9127df..1b52cd86 100644 --- a/docs/reference/credit-cards.md +++ b/docs/reference/credit-cards.md @@ -17,6 +17,7 @@ credit_cards: due_day: 20 #(4)! network: visa #(5)! number: "0007" #(6)! + expiration_date: "2029-05-01" #(7)! ``` 1. Account name @@ -25,6 +26,7 @@ credit_cards: 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 +7. The expiration date of the card The above configuration can be done from the `More > Configuration` page. Expand the `Credit Cards` section and click diff --git a/internal/config/config.go b/internal/config/config.go index f913e274..0702ba75 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -122,6 +122,7 @@ type CreditCard struct { DueDay int `json:"due_day" yaml:"due_day"` Network string `json:"network" yaml:"network"` Number string `json:"number" yaml:"number"` + ExpirationDate string `json:"expiration_date" yaml:"expiration_date"` } type Config struct { diff --git a/internal/config/schema.json b/internal/config/schema.json index d66f5175..76af9301 100644 --- a/internal/config/schema.json +++ b/internal/config/schema.json @@ -488,6 +488,11 @@ "maxLength": 4, "minLength": 4, "pattern": "^[0-9]{4}$" + }, + "expiration_date": { + "type": "string", + "description": "Expiration date of the card", + "format": "date" } }, "required": [ @@ -496,7 +501,8 @@ "statement_end_day", "due_day", "network", - "number" + "number", + "expiration_date" ], "additionalProperties": false } diff --git a/internal/generator/config.go b/internal/generator/config.go index 5fcc0f24..64612b61 100644 --- a/internal/generator/config.go +++ b/internal/generator/config.go @@ -164,6 +164,7 @@ credit_cards: due_day: 20 network: visa number: "0007" + expiration_date: "2029-05-01" ` log.Info("Generating config file: ", configFilePath) journalFilePath := filepath.Join(cwd, "main.ledger") diff --git a/internal/server/credit_card.go b/internal/server/credit_card.go index 893047aa..46875c40 100644 --- a/internal/server/credit_card.go +++ b/internal/server/credit_card.go @@ -8,6 +8,7 @@ import ( "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/service" "github.com/ananthakumaran/paisa/internal/utils" "github.com/gin-gonic/gin" "github.com/samber/lo" @@ -17,12 +18,14 @@ import ( ) 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"` + 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"` + YearlySpends map[string]map[string]decimal.Decimal `json:"yearlySpends"` + ExpirationDate time.Time `json:"expirationDate"` } type CreditCardBill struct { @@ -62,19 +65,46 @@ func GetCreditCard(db *gorm.DB, account string) gin.H { return gin.H{"found": false} } +func yearlySpends(db *gorm.DB, date time.Time, postings []posting.Posting) map[string]map[string]decimal.Decimal { + yearlySpends := make(map[string]map[string]decimal.Decimal) + for year, ps := range utils.GroupByYearCutoffAt(postings, date) { + spends := lo.Filter(ps, func(p posting.Posting, _ int) bool { + return p.Amount.IsNegative() || service.IsContraPostingRefund(db, p) + }) + + yearlySpends[year] = make(map[string]decimal.Decimal) + for month, ps := range utils.GroupByMonth(spends) { + yearlySpends[year][month] = accounting.CostSum(ps).Neg() + } + } + return yearlySpends +} + 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 } + + expirationDate, err := time.ParseInLocation("2006-01-02", creditCardConfig.ExpirationDate, time.Local) + if err != nil { + log.Fatal(err) + } + + ys := make(map[string]map[string]decimal.Decimal) + if includePostings { + ys = yearlySpends(db, expirationDate, ps) + } return CreditCardSummary{ - Account: creditCardConfig.Account, - Network: creditCardConfig.Network, - Number: creditCardConfig.Number, - Balance: balance, - Bills: bills, - CreditLimit: decimal.NewFromInt(int64(creditCardConfig.CreditLimit)), + Account: creditCardConfig.Account, + Network: creditCardConfig.Network, + Number: creditCardConfig.Number, + Balance: balance, + Bills: bills, + CreditLimit: decimal.NewFromInt(int64(creditCardConfig.CreditLimit)), + YearlySpends: ys, + ExpirationDate: expirationDate, } } diff --git a/internal/service/interest.go b/internal/service/interest.go index fce5d1a2..1c867b0a 100644 --- a/internal/service/interest.go +++ b/internal/service/interest.go @@ -59,6 +59,14 @@ func IsCapitalGains(p posting.Posting) bool { return false } +func IsRefund(p posting.Posting) bool { + if utils.IsParent(p.Account, "Income:Refund") { + return true + } + + return false +} + func IsStockSplit(db *gorm.DB, p posting.Posting) bool { if utils.IsCurrency(p.Commodity) { return false @@ -95,6 +103,20 @@ func IsSellWithCapitalGains(db *gorm.DB, p posting.Posting) bool { return false } +func IsContraPostingRefund(db *gorm.DB, p posting.Posting) bool { + t, found := transaction.GetById(db, p.TransactionID) + if !found { + return false + } + + for _, tp := range t.Postings { + if IsRefund(tp) { + return true + } + } + return false +} + func IsInterestRepayment(db *gorm.DB, p posting.Posting) bool { irepaymentCache.Do(func() { loadInterestRepaymentCache(db) }) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index e4ace623..94e44d94 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -64,6 +64,14 @@ func FYHuman(date time.Time) string { } } +func YearHumanCutOffAt(date time.Time, cutoff time.Time) string { + if date.Month() < cutoff.Month() || date.Month() == cutoff.Month() && date.Day() < cutoff.Day() { + return fmt.Sprintf("%d - %d", date.Year()-1, date.Year()%100) + } else { + return fmt.Sprintf("%d - %d", date.Year(), (date.Year()+1)%100) + } +} + func ParseFY(fy string) (time.Time, time.Time) { start, _ := time.Parse("2006", strings.Split(fy, " ")[0]) start = start.AddDate(0, int(config.GetConfig().FinancialYearStartingMonth-time.January), 0) @@ -218,6 +226,21 @@ func GroupByFY[G GroupableByDate](groupables []G) map[string][]G { return grouped } +func GroupByYearCutoffAt[G GroupableByDate](groupables []G, date time.Time) map[string][]G { + grouped := make(map[string][]G) + for _, g := range groupables { + key := YearHumanCutOffAt(g.GroupDate(), date) + ps, ok := grouped[key] + if ok { + grouped[key] = append(ps, g) + } else { + grouped[key] = []G{g} + } + + } + return grouped +} + func SumBy[C any](collection []C, iteratee func(item C) decimal.Decimal) decimal.Decimal { return lo.Reduce(collection, func(acc decimal.Decimal, item C, _ int) decimal.Decimal { return iteratee(item).Add(acc) diff --git a/src/app.scss b/src/app.scss index f785fe4d..c19c4f1e 100644 --- a/src/app.scss +++ b/src/app.scss @@ -1093,7 +1093,7 @@ div.is-hoverable:hover { .credit-card-container { display: grid; - gap: 18px; + gap: 36px; grid-template-columns: repeat(auto-fill, minmax(19rem, 25rem)); } @@ -1104,20 +1104,28 @@ div.is-hoverable:hover { flex: 1; border-radius: 0.7rem; display: flex; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3) !important; + border: 1px solid $grey-lightest; + box-shadow: + 3px 3px 3px 0 rgba(0, 0, 0, 0.05), + 10px 10px 10px 0 rgba(0, 0, 0, 0.15), + 20px 20px 20px 0 rgba(0, 0, 0, 0.15) !important; background: linear-gradient( 345deg, $grey-lightest 0%, $grey-lightest 60%, - $grey-lighter 60%, + $grey-lighter calc(60% + 1px), $grey-lighter 85%, - $grey-light 85%, + $grey-light calc(85% + 1px), $grey-light 95%, - $grey 95%, + $grey calc(95% + 1px), $grey 100% ); .chip { color: $amber-700; } + + .nfc { + color: $black; + } } diff --git a/src/dark.scss b/src/dark.scss index 1913a59d..83d29034 100644 --- a/src/dark.scss +++ b/src/dark.scss @@ -181,16 +181,21 @@ html[data-theme="dark"] { } .credit-card { - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 1) !important; + border: none; + box-shadow: + 3px 3px 3px 0 rgba(0, 0, 0, 0.7), + 10px 10px 10px 0 rgba(0, 0, 0, 0.5), + 20px 20px 20px 0 rgba(0, 0, 0, 0.4), + -1px -1px 1px 0 rgba(255, 255, 255, 0.2) !important; background: linear-gradient( 345deg, $white 0%, $white 60%, - $white-bis 60%, + $white-bis calc(60% + 1px), $white-bis 85%, - $white-ter 85%, + $white-ter calc(85% + 1px), $white-ter 95%, - $grey-lightest 95%, + $grey-lightest calc(95% + 1px), $grey-lightest 100% ); } diff --git a/src/lib/components/CreditCardCard.svelte b/src/lib/components/CreditCardCard.svelte index f5490b3b..c67ed3a1 100644 --- a/src/lib/components/CreditCardCard.svelte +++ b/src/lib/components/CreditCardCard.svelte @@ -25,13 +25,33 @@