add transaction export option
This commit is contained in:
parent
a13c553e1c
commit
3c16a53ee0
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue