[credit_card] show year wise spend

This commit is contained in:
Anantha Kumaran 2024-01-27 20:12:08 +05:30
parent 46b4820bce
commit 03d4fe2c68
19 changed files with 288 additions and 43 deletions

View File

@ -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
```

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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")

View File

@ -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,
}
}

View File

@ -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) })

View File

@ -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)

View File

@ -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;
}
}

View File

@ -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%
);
}

View File

@ -25,13 +25,33 @@
<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"
<div style="margin: 35px 0 0 15px;" class="flex items-center opacity-20">
<svg
class="chip"
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
>
<svg
class="nfc ml-1"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 8.32a7.43 7.43 0 0 1 0 7.36m3.46-9.47a11.76 11.76 0 0 1 0 11.58M12.91 4.1a15.91 15.91 0 0 1 .01 15.8M16.37 2a20.16 20.16 0 0 1 0 20"
/></svg
>
</div>
<div>
<a
@ -72,8 +92,14 @@
</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 class="has-text-weight-bold is-size-5 inline-flex items-center">
<span class="opacity-40 inline-flex flex-col mr-2" style="font-size: 0.5rem; line-height: 1;">
<span>VALID</span>
<span>THRU</span>
</span>
<span class="opacity-30"
>{creditCard.expirationDate.format("MM / YY")} &nbsp; &nbsp; &nbsp; * * * * &nbsp; {creditCard.number}</span
>
</div>
<div class="opacity-15">
<CreditCardNetwork size={48} name={creditCard.network} />

72
src/lib/credit_cards.ts Normal file
View File

@ -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());
}

View File

@ -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 {

View File

@ -1,30 +1,34 @@
<script lang="ts">
import { goto } from "$app/navigation";
import COLORS from "$lib/colors";
import BoxLabel from "$lib/components/BoxLabel.svelte";
import CreditCardCard from "$lib/components/CreditCardCard.svelte";
import DueDate from "$lib/components/DueDate.svelte";
import LevelItem from "$lib/components/LevelItem.svelte";
import TransactionCard from "$lib/components/TransactionCard.svelte";
import { renderYearlySpends } from "$lib/credit_cards";
import { iconify } from "$lib/icon";
import {
ajax,
formatCurrency,
formatPercentage,
type CreditCardBill,
type CreditCardSummary,
formatPercentage
type CreditCardSummary
} from "$lib/utils";
import { MasonryGrid } from "@egjs/svelte-grid";
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 svg: SVGElement;
let creditCard: CreditCardSummary;
let currentBill: CreditCardBill;
let found = false;
let small = true;
let rendered = false;
function lastBill(creditCard: CreditCardSummary): CreditCardBill {
return _.find(_.reverse(_.clone(creditCard.bills)), (b) => {
@ -32,12 +36,18 @@
});
}
$: if (creditCard && svg && !rendered) {
renderYearlySpends(svg, creditCard.yearlySpends);
rendered = true;
}
onMount(async () => {
({ creditCard, found } = await ajax("/api/credit_cards/:account", null, data));
currentBill = lastBill(creditCard);
if (!found) {
redirect(307, `/liabilities/credit_cards`);
return goto("/liabilities/credit_cards");
}
currentBill = lastBill(creditCard);
});
</script>
@ -46,7 +56,7 @@
<div class="columns flex-wrap">
<div class="column is-3-widescreen is-4">
{#if creditCard}
<div class="flex mb-4">
<div class="flex mb-12">
<CreditCardCard {creditCard} />
</div>
@ -84,6 +94,11 @@
value={_.sumBy(creditCard.bills, (b) => b.transactions.length).toString()}
/>
</nav>
<div class="box px-3 py-0">
<svg bind:this={svg} width="100%" />
</div>
<BoxLabel text="Year wise spends" />
{/if}
</div>
<div class="column is-9-widescreen is-8">

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"