From 03d4fe2c68d0c08e9ba5c2fa8c6470aa6c45e903 Mon Sep 17 00:00:00 2001 From: Anantha Kumaran Date: Sat, 27 Jan 2024 20:12:08 +0530 Subject: [PATCH] [credit_card] show year wise spend --- docs/reference/config.md | 2 + docs/reference/credit-cards.md | 2 + internal/config/config.go | 1 + internal/config/schema.json | 8 ++- internal/generator/config.go | 1 + internal/server/credit_card.go | 54 ++++++++++---- internal/service/interest.go | 22 ++++++ internal/utils/utils.go | 23 ++++++ src/app.scss | 18 +++-- src/dark.scss | 13 ++-- src/lib/components/CreditCardCard.svelte | 34 +++++++-- src/lib/credit_cards.ts | 72 +++++++++++++++++++ src/lib/utils.ts | 2 + .../credit_cards/[slug]/+page.svelte | 39 ++++++---- tests/fixture/eur-hledger/config.json | 8 ++- tests/fixture/eur/config.json | 8 ++- tests/fixture/inr-beancount/config.json | 8 ++- tests/fixture/inr-hledger/config.json | 8 ++- tests/fixture/inr/config.json | 8 ++- 19 files changed, 288 insertions(+), 43 deletions(-) create mode 100644 src/lib/credit_cards.ts 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 @@
-
- + +
-
- * * * *   {creditCard.number} +
+ + VALID + THRU + + {creditCard.expirationDate.format("MM / YY")}       * * * *   {creditCard.number}
diff --git a/src/lib/credit_cards.ts b/src/lib/credit_cards.ts new file mode 100644 index 00000000..b86d55ca --- /dev/null +++ b/src/lib/credit_cards.ts @@ -0,0 +1,72 @@ +import * as d3 from "d3"; +import { formatCurrencyCrude, tooltip, formatCurrency } from "./utils"; +import _ from "lodash"; +import COLORS from "./colors"; + +export function renderYearlySpends( + svgNode: SVGElement, + yearlySpends: { [year: string]: { [month: string]: number } } +) { + const BAR_HEIGHT = 20; + const svg = d3.select(svgNode), + margin = { top: 15, right: 20, bottom: 20, left: 70 }, + width = svgNode.parentElement.clientWidth - margin.left - margin.right, + g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + const color = COLORS.expenses; + + const height = BAR_HEIGHT * Object.keys(yearlySpends).length; + svg.attr("height", height + margin.top + margin.bottom); + + interface Point { + year: string; + value: number; + breakdown: { [month: string]: number }; + } + + const points: Point[] = _.chain(yearlySpends) + .map((breakdown, year) => { + const value = _.sum(_.values(breakdown)); + return { year, breakdown, value }; + }) + .sortBy((p) => p.year) + .value(); + + const x = d3.scaleLinear().range([0, width]); + const y = d3.scaleBand().range([height, 0]).paddingInner(0.2).paddingOuter(0); + + y.domain(points.map((p) => p.year)); + x.domain([0, d3.max(points, (p: Point) => p.value)]); + + g.append("g") + .attr("class", "axis y") + .attr("transform", "translate(0," + height + ")") + .call(d3.axisBottom(x).tickSize(-height).tickFormat(formatCurrencyCrude)); + + g.append("g").attr("class", "axis y dark").call(d3.axisLeft(y)); + + g.append("g") + .selectAll("rect") + .data(points) + .join("rect") + .attr("stroke-opacity", 0.6) + .attr("fill-opacity", 0.4) + .attr("stroke", color) + .attr("fill", color) + .attr("data-tippy-content", (d) => { + return tooltip( + _.map(d.breakdown, (value, month) => { + return [month, [formatCurrency(value), "has-text-right has-text-weight-bold"]]; + }), + { total: formatCurrency(d.value), header: d.year } + ); + }) + .attr("x", x(0)) + .attr("y", function (d) { + return y(d.year) + (y.bandwidth() - Math.min(y.bandwidth(), BAR_HEIGHT)) / 2; + }) + .attr("width", function (d) { + return x(d.value) - x(0); + }) + .attr("height", y.bandwidth()); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9f0d057d..19032030 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -502,6 +502,8 @@ export interface CreditCardSummary { balance: number; bills: CreditCardBill[]; creditLimit: number; + expirationDate: dayjs.Dayjs; + yearlySpends: { [year: string]: { [month: string]: number } }; } export interface GoalSummary { diff --git a/src/routes/(app)/liabilities/credit_cards/[slug]/+page.svelte b/src/routes/(app)/liabilities/credit_cards/[slug]/+page.svelte index 3aa7d42e..fba6aba7 100644 --- a/src/routes/(app)/liabilities/credit_cards/[slug]/+page.svelte +++ b/src/routes/(app)/liabilities/credit_cards/[slug]/+page.svelte @@ -1,30 +1,34 @@ @@ -46,7 +56,7 @@
{#if creditCard} -
+
@@ -84,6 +94,11 @@ value={_.sumBy(creditCard.bills, (b) => b.transactions.length).toString()} /> + +
+ +
+ {/if}
diff --git a/tests/fixture/eur-hledger/config.json b/tests/fixture/eur-hledger/config.json index a803a471..a1bdb917 100644 --- a/tests/fixture/eur-hledger/config.json +++ b/tests/fixture/eur-hledger/config.json @@ -249,6 +249,11 @@ "minimum": 1, "type": "integer" }, + "expiration_date": { + "description": "Expiration date of the card", + "format": "date", + "type": "string" + }, "network": { "description": "Network of the card", "enum": [ @@ -282,7 +287,8 @@ "statement_end_day", "due_day", "network", - "number" + "number", + "expiration_date" ], "type": "object", "ui:header": "account" diff --git a/tests/fixture/eur/config.json b/tests/fixture/eur/config.json index e3161ea8..2a0db3ac 100644 --- a/tests/fixture/eur/config.json +++ b/tests/fixture/eur/config.json @@ -249,6 +249,11 @@ "minimum": 1, "type": "integer" }, + "expiration_date": { + "description": "Expiration date of the card", + "format": "date", + "type": "string" + }, "network": { "description": "Network of the card", "enum": [ @@ -282,7 +287,8 @@ "statement_end_day", "due_day", "network", - "number" + "number", + "expiration_date" ], "type": "object", "ui:header": "account" diff --git a/tests/fixture/inr-beancount/config.json b/tests/fixture/inr-beancount/config.json index 0ab96460..e69d290a 100644 --- a/tests/fixture/inr-beancount/config.json +++ b/tests/fixture/inr-beancount/config.json @@ -257,6 +257,11 @@ "minimum": 1, "type": "integer" }, + "expiration_date": { + "description": "Expiration date of the card", + "format": "date", + "type": "string" + }, "network": { "description": "Network of the card", "enum": [ @@ -290,7 +295,8 @@ "statement_end_day", "due_day", "network", - "number" + "number", + "expiration_date" ], "type": "object", "ui:header": "account" diff --git a/tests/fixture/inr-hledger/config.json b/tests/fixture/inr-hledger/config.json index dcd6e0d7..eee8cb73 100644 --- a/tests/fixture/inr-hledger/config.json +++ b/tests/fixture/inr-hledger/config.json @@ -256,6 +256,11 @@ "minimum": 1, "type": "integer" }, + "expiration_date": { + "description": "Expiration date of the card", + "format": "date", + "type": "string" + }, "network": { "description": "Network of the card", "enum": [ @@ -289,7 +294,8 @@ "statement_end_day", "due_day", "network", - "number" + "number", + "expiration_date" ], "type": "object", "ui:header": "account" diff --git a/tests/fixture/inr/config.json b/tests/fixture/inr/config.json index 37b8cf19..52f5f145 100644 --- a/tests/fixture/inr/config.json +++ b/tests/fixture/inr/config.json @@ -256,6 +256,11 @@ "minimum": 1, "type": "integer" }, + "expiration_date": { + "description": "Expiration date of the card", + "format": "date", + "type": "string" + }, "network": { "description": "Network of the card", "enum": [ @@ -289,7 +294,8 @@ "statement_end_day", "due_day", "network", - "number" + "number", + "expiration_date" ], "type": "object", "ui:header": "account"