Add transaction list for accounts

This commit is contained in:
Knut Ahlers 2024-01-17 23:02:41 +01:00
parent 6eb231f4d0
commit 5779e9e8a9
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
11 changed files with 841 additions and 17 deletions

View file

@ -0,0 +1,502 @@
<template>
<div>
<div class="container-fluid">
<div class="row">
<div class="col">
<!-- TODO: Add time-selector -->
<range-selector
v-model="timeRange"
/>
</div>
<div class="col fs-4 text-center">
{{ accountIdToName[accountId] }}
</div>
<div class="col d-flex align-items-center justify-content-end">
<div class="btn-group btn-group-sm">
<button
class="btn"
@click="showAddTransaction = true"
>
<i class="fas fa-fw fa-plus-circle mr-1" />
Add Transaction
</button>
<button
class="btn"
data-bs-toggle="modal"
data-bs-target="#transferMoneyModal"
>
<i class="fas fa-fw fa-arrow-right-arrow-left mr-1" />
Add Transfer
</button>
<button
class="btn text-danger"
:disabled="selectedTx.length < 1"
@click="deleteSelected"
>
<i class="fas fa-fw fa-trash mr-1" />
Delete Selected
</button>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col">
<table class="table table-striped small">
<thead>
<tr>
<th class="minimized-column">
<input
type="checkbox"
@change="updateSelectAll"
>
</th>
<th class="minimized-column">
Date
</th>
<th>Payee</th>
<th>Category</th>
<th>Description</th>
<th class="minimized-column text-end">
Amount
</th>
<th class="minimized-column">
<i class="fas fa-copyright" />
</th>
</tr>
</thead>
<tbody>
<tr v-if="showAddTransaction">
<td />
<td>
<input
v-model="form.date"
class="form-control form-control-sm"
type="date"
>
</td>
<td>
<input
v-model="form.payee"
class="form-control form-control-sm"
type="text"
>
</td>
<td>
<select
v-model="form.category"
class="form-select form-select-sm"
>
<option
v-for="cat in categories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
</select>
</td>
<td>
<input
v-model="form.description"
class="form-control form-control-sm"
type="text"
>
</td>
<td>
<input
v-model="form.amount"
class="form-control form-control-sm"
type="number"
step="0.01"
@keypress.enter="createTransaction"
>
</td>
<td class="align-middle">
<input
v-model="form.cleared"
type="checkbox"
@keypress.enter="createTransaction"
>
</td>
</tr>
<tr
v-for="tx in sortedTransactions"
:key="tx.id"
>
<td>
<input
v-model="selectedTxRaw[tx.id]"
type="checkbox"
>
</td>
<td class="minimized-column">
{{ new Date(tx.time).toLocaleDateString() }}
</td>
<td>{{ tx.payee }}</td>
<td>{{ accountIdToName[tx.category] }}</td>
<td>{{ tx.description }}</td>
<td :class="{'minimized-column text-end': true, 'text-danger': tx.amount < 0}">
{{ formatNumber(tx.amount) }}
</td>
<td>
<a
href="#"
:class="{'text-decoration-none':true, 'text-muted': !tx.cleared, 'text-success': tx.cleared}"
@click.prevent="markCleared(tx.id, !tx.cleared)"
>
<i class="fas fa-copyright" />
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div
id="transferMoneyModal"
ref="transferMoneyModal"
class="modal fade"
tabindex="-1"
aria-labelledby="transferMoneyModalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h1
id="transferMoneyModalLabel"
class="modal-title fs-5"
>
Transfer Money
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
/>
</div>
<div class="modal-body">
<div class="mb-3">
<label
for="transferMoneyModalFrom"
class="form-label"
>From</label>
<select
id="transferMoneyModalFrom"
v-model="modals.createTransfer.from"
class="form-select"
>
<option
v-for="acc in transferableAccounts"
:key="acc.id"
:value="acc.id"
>
{{ acc.name }}
</option>
</select>
</div>
<div class="mb-3">
<label
for="transferMoneyModalTo"
class="form-label"
>To</label>
<select
id="transferMoneyModalTo"
v-model="modals.createTransfer.to"
class="form-select"
>
<option
v-for="acc in transferableAccounts"
:key="acc.id"
:value="acc.id"
>
{{ acc.name }}
</option>
</select>
</div>
<div class="mb-3">
<label
for="transferMoneyModalCategory"
class="form-label"
>Category</label>
<select
id="transferMoneyModalCategory"
v-model="modals.createTransfer.category"
class="form-select"
:disabled="!transferCategoryActive"
>
<option
v-for="cat in categories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
</select>
</div>
<div class="mb-3">
<label
for="transferMoneyModalAmount"
class="form-label"
>Amount</label>
<div class="input-group">
<input
id="transferMoneyModalAmount"
v-model.number="modals.createTransfer.amount"
type="number"
min="0.01"
step="0.01"
class="form-control text-end"
>
<span class="input-group-text"></span>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-primary"
:disabled="!transferModalValid"
@click="transferMoney"
>
<i class="fas fa-fw fa-arrow-right-arrow-left mr-1" />
Transfer
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
/* eslint-disable sort-imports */
import { Modal } from 'bootstrap'
import { formatNumber, responseToJSON } from '../helpers'
import rangeSelector from './rangeSelector.vue'
export default {
components: { rangeSelector },
computed: {
accountIdToName() {
return Object.fromEntries(this.accounts.map(acc => [acc.id, acc.name]))
},
accountTypes() {
return Object.fromEntries(this.accounts.map(acc => [acc.id, acc.type]))
},
categories() {
const cats = this.accounts.filter(acc => acc.type === 'category')
cats.sort((a, b) => a.name.localeCompare(b.name))
return cats
},
selectedTx() {
return Object.entries(this.selectedTxRaw)
.filter(e => e[1])
.map(e => e[0])
},
sortedTransactions() {
const tx = [...this.transactions]
tx.sort((b, a) => new Date(a.time).getTime() - new Date(b.time).getTime())
return tx
},
transferCategoryActive() {
return this.modals.createTransfer.from && this.modals.createTransfer.to && this.accountTypes[this.modals.createTransfer.from] !== this.accountTypes[this.modals.createTransfer.to]
},
transferModalValid() {
if (!this.modals.createTransfer.from || !this.modals.createTransfer.to) {
return false
}
if (this.modals.createTransfer.from === this.modals.createTransfer.to) {
return false
}
const tFrom = this.accountTypes[this.modals.createTransfer.from]
const tTo = this.accountTypes[this.modals.createTransfer.to]
if (tFrom !== tTo && !this.modals.createTransfer.category) {
return false
}
if (tFrom === tTo && this.modals.createTransfer.category) {
return false
}
return true
},
transferableAccounts() {
const accs = (this.accounts || []).filter(acc => acc.type !== 'category')
accs.sort((a, b) => a.name.localeCompare(b.name))
return accs
},
},
data() {
return {
form: {
amount: 0,
category: '',
cleared: false,
date: new Date().toISOString()
.split('T')[0],
description: '',
payee: '',
},
modals: {
createTransfer: {
amount: 0,
category: '',
from: '',
to: '',
},
},
selectedTxRaw: {},
showAddTransaction: false,
timeRange: {},
transactions: [],
}
},
methods: {
createTransaction() {
return fetch('/api/transactions', {
body: JSON.stringify({
...this.form,
account: this.accountId,
time: new Date(this.form.date),
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
.then(responseToJSON)
.then(() => {
this.$emit('update-accounts')
this.fetchTransactions()
this.showAddTransaction = false
this.form = {
amount: 0,
category: '',
cleared: false,
date: this.form.date,
description: '',
payee: '',
}
})
},
deleteSelected() {
const actions = []
for (const id of this.selectedTx) {
actions.push(fetch(`/api/transactions/${id}`, {
method: 'DELETE',
}))
}
Promise.all(actions)
.then(() => {
this.$emit('update-accounts')
this.fetchTransactions()
})
},
fetchTransactions() {
const since = this.timeRange.start.toISOString()
const until = this.timeRange.end.toISOString()
return fetch(`/api/accounts/${this.accountId}/transactions?since=${since}&until=${until}`)
.then(resp => resp.json())
.then(txs => {
this.transactions = txs
this.selectedTxRaw = {}
})
},
formatNumber,
markCleared(txId, cleared) {
return fetch(`/api/transactions/${txId}?cleared=${cleared}`, {
method: 'PATCH',
})
.then(() => this.fetchTransactions())
},
transferMoney() {
const params = new URLSearchParams()
params.set('amount', this.modals.createTransfer.amount.toFixed(2))
if (this.modals.createTransfer.category) {
params.set('category', this.modals.createTransfer.category)
}
return fetch(`/api/accounts/${this.modals.createTransfer.from}/transfer/${this.modals.createTransfer.to}?${params.toString()}`, {
method: 'PUT',
})
.then(() => {
this.$emit('update-accounts')
Modal.getInstance(this.$refs.transferMoneyModal).toggle()
this.modals.createTransfer = {
amount: 0,
category: '',
from: '',
to: this.accountId,
}
})
},
updateSelectAll(evt) {
this.selectedTxRaw = Object.fromEntries(this.transactions.map(tx => [tx.id, evt.target.checked]))
},
},
mounted() {
this.modals.createTransfer.to = this.accountId
this.fetchTransactions()
},
name: 'AccountingAppAccountOverview',
props: {
accountId: {
required: true,
type: String,
},
accounts: {
required: true,
type: Array,
},
},
watch: {
accountId(to) {
this.modals.createTransfer.to = to
this.fetchTransactions()
},
timeRange() {
this.fetchTransactions()
},
},
}
</script>

View file

@ -22,7 +22,7 @@
<div class="d-flex align-items-center">
<i class="fas fa-fw fa-credit-card me-1" /> Budget
<span :class="{'ms-auto': true, 'text-danger': budgetSum < 0}">
{{ budgetSum.toFixed(2) }}
{{ formatNumber(budgetSum) }}
</span>
</div>
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
@ -31,11 +31,11 @@
v-for="acc in budgetAccounts"
:key="acc.id"
class="d-flex align-items-center text-white text-decoration-none"
:to="{ name: 'account-transactions', params: { id: acc.id }}"
:to="{ name: 'account-transactions', params: { accountId: acc.id }}"
>
{{ acc.name }}
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
{{ acc.balance.toFixed(2) }}
{{ formatNumber(acc.balance) }}
</span>
</router-link>
</li>
@ -45,7 +45,7 @@
<div class="d-flex align-items-center">
<i class="fas fa-fw fa-coin me-1" /> Tracking
<span :class="{'ms-auto': true, 'text-danger': trackingSum < 0}">
{{ trackingSum.toFixed(2) }}
{{ formatNumber(trackingSum) }}
</span>
</div>
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
@ -54,11 +54,11 @@
v-for="acc in trackingAccounts"
:key="acc.id"
class="d-flex align-items-center text-white text-decoration-none"
:to="{ name: 'account-transactions', params: { id: acc.id }}"
:to="{ name: 'account-transactions', params: { accountId: acc.id }}"
>
{{ acc.name }}
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
{{ acc.balance.toFixed(2) }}
{{ formatNumber(acc.balance) }}
</span>
</router-link>
</li>
@ -79,14 +79,14 @@
ref="createAccountModal"
class="modal fade"
tabindex="-1"
aria-labelledby="exampleModalLabel"
aria-labelledby="createAccountModalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h1
id="exampleModalLabel"
id="createAccountModalLabel"
class="modal-title fs-5"
>
Add Account
@ -166,8 +166,10 @@
</template>
<script>
/* eslint-disable sort-imports */
import { Modal } from 'bootstrap'
import { formatNumber } from '../helpers'
import { unallocatedMoneyAcc } from '../constants'
export default {
@ -266,6 +268,8 @@ export default {
}
})
},
formatNumber,
},
name: 'AccountingAppSidebar',

View file

@ -10,7 +10,10 @@
/>
</div>
<div class="d-flex flex-column flex-grow-1 py-3">
<router-view :accounts="accounts" />
<router-view
:accounts="accounts"
@updateAccounts="fetchAccounts"
/>
</div>
</div>
</template>

View file

@ -1,12 +1,15 @@
<template>
<div class="container-fluid">
<div class="row">
<div class="col">
<!-- TODO: Add time-selector -->
<div class="col d-flex align-items-center">
<range-selector
v-model="timeRange"
:multi-month="false"
/>
</div>
<div class="col d-flex align-items-center justify-content-end">
<div :class="unallocatedMoneyClass">
<span class="fs-4">{{ unallocatedMoney.toFixed(2) }} </span>
<span class="fs-4">{{ formatNumber(unallocatedMoney) }} </span>
<span class="small">Unallocated</span>
</div>
</div>
@ -17,6 +20,9 @@
<thead>
<tr>
<th>Category</th>
<th class="text-end">
Allocated
</th>
<th class="text-end">
Activity
</th>
@ -31,9 +37,14 @@
:key="cat.id"
>
<td>{{ cat.name }}</td>
<td>&nbsp;</td>
<td :class="{'text-end': true, 'text-danger': (allocatedByCategory[cat.id] || 0) < 0}">
{{ formatNumber(allocatedByCategory[cat.id] || 0) }}
</td>
<td :class="{'text-end': true, 'text-danger': (activityByCategory[cat.id] || 0) < 0}">
{{ formatNumber(activityByCategory[cat.id] || 0) }}
</td>
<td :class="{'text-end': true, 'text-danger': cat.balance < 0}">
{{ cat.balance.toFixed(2) }}
{{ formatNumber(cat.balance) }}
</td>
</tr>
</tbody>
@ -44,10 +55,32 @@
</template>
<script>
import { formatNumber } from '../helpers'
import rangeSelector from './rangeSelector.vue'
import { unallocatedMoneyAcc } from '../constants'
export default {
components: { rangeSelector },
computed: {
activityByCategory() {
return this.transactions
.filter(tx => tx.account)
.reduce((alloc, tx) => {
alloc[tx.category] = (alloc[tx.category] || 0) + tx.amount
return alloc
}, {})
},
allocatedByCategory() {
return this.transactions
.filter(tx => !tx.account)
.reduce((alloc, tx) => {
alloc[tx.category] = (alloc[tx.category] || 0) + tx.amount
return alloc
}, {})
},
categories() {
const accounts = this.accounts
.filter(acc => acc.type === 'category')
@ -78,6 +111,28 @@ export default {
},
},
data() {
return {
timeRange: {},
transactions: [],
}
},
methods: {
fetchTransactions() {
const since = this.timeRange.start.toISOString()
const until = this.timeRange.end.toISOString()
return fetch(`/api/transactions?since=${since}&until=${until}`)
.then(resp => resp.json())
.then(txs => {
this.transactions = txs
})
},
formatNumber,
},
name: 'AccountingAppBudgetDashboard',
props: {
@ -86,5 +141,11 @@ export default {
type: Array,
},
},
watch: {
timeRange() {
this.fetchTransactions()
},
},
}
</script>

View file

@ -0,0 +1,165 @@
<template>
<div class="d-inline-flex align-items-center">
<span
v-if="multiMonth"
class="me-1"
>From:</span>
<div class="input-group">
<select
v-model="dateComponents.fromMonth"
class="form-select"
>
<option
v-for="(name, idx) in monthNames"
:key="idx"
:value="idx"
>
{{ name }}
</option>
</select>
<select
v-model="dateComponents.fromYear"
class="form-select"
>
<option
v-for="year in years"
:key="year"
:value="year"
>
{{ year }}
</option>
</select>
</div>
<template v-if="multiMonth">
<span class="me-1 ms-2">To:</span>
<div class="input-group">
<select
v-model="dateComponents.toMonth"
class="form-select"
>
<option
v-for="(name, idx) in monthNames"
:key="idx"
:value="idx"
>
{{ name }}
</option>
</select>
<select
v-model="dateComponents.toYear"
class="form-select"
>
<option
v-for="year in years"
:key="year"
:value="year"
>
{{ year }}
</option>
</select>
</div>
</template>
</div>
</template>
<script>
export default {
computed: {
monthNames() {
const format = new Intl
.DateTimeFormat(undefined, { month: 'long' }).format
return [...Array(12).keys()]
.map(m => format(new Date(Date.UTC(2021, m))))
},
years() {
return [...Array(this.endYear - this.startYear).keys()]
.map(y => y + this.startYear)
},
},
created() {
const date = new Date()
const start = this.modelValue.start || new Date(date.getFullYear(), date.getMonth(), 1)
const end = this.modelValue.end || new Date(date.getFullYear(), date.getMonth() + 1, 0)
this.dateComponents = {
fromMonth: start.getMonth(),
fromYear: start.getFullYear(),
toMonth: end.getMonth(),
toYear: end.getFullYear(),
}
},
data() {
return {
dateComponents: {
fromMonth: 0,
fromYear: 0,
toMonth: 0,
toYear: 0,
},
}
},
emits: ['update:modelValue'],
methods: {
emitRange() {
this.start = new Date(this.dateComponents.fromYear, this.dateComponents.fromMonth, 1)
if (this.multiMonth) {
this.end = new Date(this.dateComponents.toYear, this.dateComponents.toMonth + 1, 1, 0)
} else {
this.end = new Date(this.dateComponents.fromYear, this.dateComponents.fromMonth + 1, 1, 0)
}
this.$emit('update:modelValue', { end: this.end, start: this.start })
},
},
mounted() {
this.emitRange()
},
name: 'AccountingAppRangeSelector',
props: {
endYear: {
default: 2034,
type: Number,
},
modelValue: {
default: () => ({}),
type: Object,
},
multiMonth: {
default: true,
type: Boolean,
},
startYear: {
default: 2020,
type: Number,
},
},
watch: {
'dateComponents.fromMonth'() {
this.emitRange()
},
'dateComponents.fromYear'() {
this.emitRange()
},
'dateComponents.toMonth'() {
this.emitRange()
},
'dateComponents.toYear'() {
this.emitRange()
},
},
}
</script>

43
frontend/helpers.js Normal file
View file

@ -0,0 +1,43 @@
export function formatNumber(number, thousandSep = ' ', decimalSep = '.', places = 2) {
if (isNaN(number)) {
return number
}
// Fix x.99999999999 database fuckups
number = Math.round(number * Math.pow(10, 2)) / Math.pow(10, 2)
let result = number < 0 ? '-' : ''
number = Math.abs(number)
if (number >= Number.MAX_SAFE_INTEGER) {
return result + number.toFixed(places)
}
let place = Math.ceil(Math.log10(number))
if (place < 3) {
return result + number.toFixed(places).replace('.', decimalSep)
}
while (place--) {
result += number / 10 ** place % 10 | 0
if (place > 0 && place % 3 === 0) {
result += thousandSep
}
}
return result + decimalSep + number.toFixed(places).split('.')[1]
}
/**
* Parses the response to JSON and throws an exception in case the
* request was non-2xx
*
* @param {Response} resp Response from a `fetch` request
*/
export function responseToJSON(resp) {
if (resp.status > 299) {
throw new Error(`non-2xx status code: ${resp.status}`)
}
return resp.json()
}

View file

@ -1,10 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router'
import accountOverview from './components/accountOverview.vue'
import budgetDashboard from './components/budgetDashboard.vue'
const routes = [
{ component: budgetDashboard, name: 'budget', path: '/' },
{ component: null, name: 'account-transactions', path: '/accounts/:id' },
{ component: accountOverview, name: 'account-transactions', path: '/accounts/:accountId', props: true },
]
const router = createRouter({

View file

@ -8,3 +8,8 @@ $fa-font-path: "../node_modules/@fortawesome/fontawesome-pro/webfonts";
@import "../node_modules/@fortawesome/fontawesome-pro/scss/fontawesome.scss";
@import "../node_modules/@fortawesome/fontawesome-pro/scss/solid.scss";
@import "../node_modules/@fortawesome/fontawesome-pro/scss/brands.scss";
.minimized-column {
width: 0.1%;
white-space: nowrap;
}

View file

@ -45,6 +45,9 @@ func RegisterHandler(apiRouter *mux.Router, dbc *database.Client, logger *logrus
HandleFunc("/accounts/{id}/transfer/{to}", as.handleTransferMoney).
Methods(http.MethodPut)
apiRouter.
HandleFunc("/transactions", as.handleListTransactions).
Methods(http.MethodGet)
apiRouter.
HandleFunc("/transactions", as.handleCreateTransaction).
Methods(http.MethodPost)

View file

@ -75,6 +75,27 @@ func (a apiServer) handleGetTransactionByID(w http.ResponseWriter, r *http.Reque
a.jsonResponse(w, http.StatusOK, tx)
}
func (a apiServer) handleListTransactions(w http.ResponseWriter, r *http.Request) {
var (
since time.Time
until = time.Now()
)
if v, err := time.Parse(time.RFC3339, r.URL.Query().Get("since")); err == nil {
since = v
}
if v, err := time.Parse(time.RFC3339, r.URL.Query().Get("until")); err == nil {
until = v
}
txs, err := a.dbc.ListTransactions(since, until)
if err != nil {
a.errorResponse(w, err, "getting transactions", http.StatusInternalServerError)
return
}
a.jsonResponse(w, http.StatusOK, txs)
}
func (a apiServer) handleListTransactionsByAccount(w http.ResponseWriter, r *http.Request) {
accid, err := uuid.Parse(mux.Vars(r)["id"])
if err != nil {

View file

@ -6,6 +6,7 @@ package database
import (
"errors"
"fmt"
"math"
"time"
"github.com/Luzifer/go_helpers/v2/backoff"
@ -173,7 +174,8 @@ func (c *Client) ListAccountBalances(showHidden bool) (a []AccountBalance, err e
}
if v != nil {
ab.Balance = *v
// Fix database doing e-15 stuff by rounding to full cents
ab.Balance = math.Round(*v*100) / 100 //nolint:gomnd
}
a = append(a, ab)
@ -224,6 +226,20 @@ func (c *Client) ListAccountsByType(at AccountType, showHidden bool) (a []Accoun
return a, nil
}
// ListTransactions retrieves all transactions
func (c *Client) ListTransactions(since, until time.Time) (txs []Transaction, err error) {
if err = c.retryRead(func(db *gorm.DB) error {
return db.
Where("time >= ? and time <= ?", since, until).
Find(&txs).
Error
}); err != nil {
return txs, fmt.Errorf("listing transactions: %w", err)
}
return txs, nil
}
// ListTransactionsByAccount retrieves all transactions for an account
// or category
func (c *Client) ListTransactionsByAccount(acc uuid.UUID, since, until time.Time) (txs []Transaction, err error) {