add transaction export option

This commit is contained in:
Anantha Kumaran 2024-02-07 19:04:46 +05:30
parent a13c553e1c
commit 3c16a53ee0
7 changed files with 192 additions and 1 deletions

117
internal/accounting/pnl.go Normal file
View File

@ -0,0 +1,117 @@
package accounting
import (
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/model/transaction"
"github.com/shopspring/decimal"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
)
type BalancedPosting struct {
From posting.Posting `json:"from"`
To posting.Posting `json:"to"`
}
type PostingPair struct {
Posting posting.Posting
CounterPosting posting.Posting
}
func balancePostings(postings []posting.Posting) []PostingPair {
EPSILON := decimal.NewFromFloat(0.01)
var pairs []PostingPair
pending := slices.Clone(postings)
for len(pending) > 0 {
var pair PostingPair
pair.Posting = pending[0]
pending = pending[1:]
found := false
for i, p := range pending {
if pair.Posting.Commodity == p.Commodity && pair.Posting.Quantity.Neg().Equal(p.Quantity) {
pair.CounterPosting = p
pending = slices.Delete(pending, i, i+1)
pairs = append(pairs, pair)
found = true
break
}
}
if !found {
for i, p := range pending {
if pair.Posting.Amount.Neg().Equal(p.Amount) {
pair.CounterPosting = p
pending = slices.Delete(pending, i, i+1)
pairs = append(pairs, pair)
found = true
break
}
}
}
if !found {
RESTART:
for i, p := range pending {
if (pair.Posting.Amount.Sign() == 1 && p.Amount.Sign() == -1) ||
(pair.Posting.Amount.Sign() == -1 && p.Amount.Sign() == 1) {
if pair.Posting.Amount.Abs().Equal(p.Amount.Abs()) {
pair.CounterPosting = p
pending = slices.Delete(pending, i, i+1)
pairs = append(pairs, pair)
found = true
break
} else if pair.Posting.Amount.Abs().LessThan(p.Amount.Abs()) {
counter, remaining := p.Split(pair.Posting.Amount.Neg())
pair.CounterPosting = counter
pending[i] = remaining
pairs = append(pairs, pair)
found = true
break
} else {
current, remaining := pair.Posting.Split(p.Amount.Neg())
pair.Posting = current
pair.CounterPosting = p
pending = slices.Delete(pending, i, i+1)
pairs = append(pairs, pair)
pair = PostingPair{}
pair.Posting = remaining
goto RESTART
}
}
}
}
if !found && pair.Posting.Amount.Abs().GreaterThan(EPSILON) {
log.Infof("No counter posting found for %v \npending: %v \npairs: %v e: %v", pair.Posting, pending, pairs, EPSILON)
break
}
}
return pairs
}
func BuildBalancedPostings(transactions []transaction.Transaction) []BalancedPosting {
var balancedPostings []BalancedPosting
for _, t := range transactions {
postings := t.Postings
pairs := balancePostings(postings)
for _, pair := range pairs {
var from, to posting.Posting
if pair.Posting.Quantity.IsPositive() {
to = pair.Posting
from = pair.CounterPosting
} else {
to = pair.CounterPosting
from = pair.Posting
}
balancedPostings = append(balancedPostings, BalancedPosting{
From: from,
To: to,
})
}
}
return balancedPostings
}

View File

@ -87,6 +87,17 @@ func (p Posting) WithQuantity(quantity decimal.Decimal) Posting {
return clone
}
func (p Posting) WithAmount(amount decimal.Decimal) Posting {
clone := p
clone.Amount = amount
clone.Quantity = amount.Div(p.Price())
return clone
}
func (p Posting) Split(amount decimal.Decimal) (Posting, Posting) {
return p.WithAmount(amount), p.WithAmount(p.Amount.Sub(amount))
}
func (p Posting) Behaviours() []string {
if p.behaviours == nil {
p.behaviours = Behaviours(p.Account)

View File

@ -194,6 +194,10 @@ func Build(db *gorm.DB, enableCompression bool) *gin.Engine {
c.JSON(200, GetPriceAutoCompletions(db, autoCompleteRequest))
})
router.GET("/api/transaction/balanced", func(c *gin.Context) {
c.JSON(200, GetBalancedPostings(db))
})
router.GET("/api/transaction", func(c *gin.Context) {
c.JSON(200, GetTransactions(db))
})

View File

@ -1,10 +1,12 @@
package server
import (
"sort"
"github.com/ananthakumaran/paisa/internal/accounting"
"github.com/ananthakumaran/paisa/internal/model/transaction"
"github.com/ananthakumaran/paisa/internal/query"
"github.com/gin-gonic/gin"
"sort"
"gorm.io/gorm"
)
@ -19,6 +21,14 @@ func GetTransactions(db *gorm.DB) gin.H {
return gin.H{"transactions": transactions}
}
func GetBalancedPostings(db *gorm.DB) gin.H {
postings := query.Init(db).Desc().All()
transactions := transaction.Build(postings)
balancePostings := accounting.BuildBalancedPostings(transactions)
return gin.H{"balancedPostings": balancePostings}
}
func GetLatestTransactions(db *gorm.DB) []transaction.Transaction {
postings := query.Init(db).Desc().Limit(200).All()
transactions := transaction.Build(postings)

27
src/lib/export.ts Normal file
View File

@ -0,0 +1,27 @@
import type { BalancedPosting } from "./utils";
import Papa from "papaparse";
export function download(balancedPostings: BalancedPosting[]) {
const rows = balancedPostings.map((balancedPosting) => {
console.log(balancedPosting);
return {
Date: balancedPosting.from.date.toISOString(),
Payee: balancedPosting.from.payee,
FromAccount: balancedPosting.from.account,
FromQuantity: balancedPosting.from.quantity,
FromAmount: balancedPosting.from.amount,
FromCommodity: balancedPosting.from.commodity,
ToAccount: balancedPosting.to.account,
ToQuantity: balancedPosting.to.quantity,
ToAmount: balancedPosting.to.amount,
ToCommodity: balancedPosting.to.commodity
};
});
const csv = Papa.unparse(rows);
const downloadLink = document.createElement("a");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
downloadLink.href = window.URL.createObjectURL(blob);
downloadLink.download = "paisa-transactions.csv";
downloadLink.click();
}

View File

@ -106,6 +106,11 @@ export interface Transaction {
postings: Posting[];
}
export interface BalancedPosting {
from: Posting;
to: Posting;
}
export interface Price {
id: string;
date: dayjs.Dayjs;
@ -563,6 +568,9 @@ export function ajax(
): Promise<{ liability_breakdowns: LiabilityBreakdown[] }>;
export function ajax(route: "/api/price"): Promise<{ prices: Record<string, Price[]> }>;
export function ajax(route: "/api/transaction"): Promise<{ transactions: Transaction[] }>;
export function ajax(
route: "/api/transaction/balanced"
): Promise<{ balancedPostings: BalancedPosting[] }>;
export function ajax(route: "/api/networth"): Promise<{
networthTimeline: Networth[];
xirr: number;

View File

@ -12,6 +12,7 @@
import SearchQuery from "$lib/components/SearchQuery.svelte";
import { editorState } from "$lib/search_query_editor";
import { get } from "svelte/store";
import { download } from "$lib/export";
let buldEditOpen = false;
let transactions: T[] = null;
@ -61,6 +62,11 @@
newFiles = files;
}
async function downloadTransactions() {
const { balancedPostings } = await ajax("/api/transaction/balanced");
download(balancedPostings);
}
function showPreview(detail: any) {
({ newFiles, updatedTransactionsCount } = bulkEdit.applyChanges(
files,
@ -148,6 +154,14 @@
<div class="level-item">
<p class="is-6"><b>{filtered.length}</b> transaction(s)</p>
</div>
<div class="level-item">
<a on:click={(_e) => downloadTransactions()}>
<span class="icon is-small">
<i class="fa-solid fa-file-arrow-down"></i>
</span>
download
</a>
</div>
</div>
</nav>
</div>