Implement transfer creation #19
@ -130,7 +130,7 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchAccounts = `-- name: SearchAccounts :many
|
const searchAccounts = `-- name: SearchAccounts :many
|
||||||
SELECT accounts.id, accounts.budget_id, accounts.name FROM accounts
|
SELECT accounts.id, accounts.budget_id, accounts.name, true as is_account FROM accounts
|
||||||
WHERE accounts.budget_id = $1
|
WHERE accounts.budget_id = $1
|
||||||
AND accounts.name LIKE $2
|
AND accounts.name LIKE $2
|
||||||
ORDER BY accounts.name
|
ORDER BY accounts.name
|
||||||
@ -142,9 +142,10 @@ type SearchAccountsParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SearchAccountsRow struct {
|
type SearchAccountsRow struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
BudgetID uuid.UUID
|
BudgetID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
|
IsAccount bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams) ([]SearchAccountsRow, error) {
|
func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams) ([]SearchAccountsRow, error) {
|
||||||
@ -156,7 +157,12 @@ func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams)
|
|||||||
var items []SearchAccountsRow
|
var items []SearchAccountsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i SearchAccountsRow
|
var i SearchAccountsRow
|
||||||
if err := rows.Scan(&i.ID, &i.BudgetID, &i.Name); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.BudgetID,
|
||||||
|
&i.Name,
|
||||||
|
&i.IsAccount,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
@ -83,6 +83,10 @@ func (n Numeric) Sub(other Numeric) Numeric {
|
|||||||
panic("Cannot subtract with different exponents")
|
panic("Cannot subtract with different exponents")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n Numeric) Neg() Numeric {
|
||||||
|
return Numeric{pgtype.Numeric{Exp: n.Exp, Int: big.NewInt(-1 * n.Int.Int64()), Status: n.Status}}
|
||||||
|
}
|
||||||
|
|
||||||
func (n Numeric) Add(other Numeric) Numeric {
|
func (n Numeric) Add(other Numeric) Numeric {
|
||||||
left := n
|
left := n
|
||||||
right := other
|
right := other
|
||||||
|
@ -22,7 +22,7 @@ GROUP BY accounts.id, accounts.name
|
|||||||
ORDER BY accounts.name;
|
ORDER BY accounts.name;
|
||||||
|
|
||||||
-- name: SearchAccounts :many
|
-- name: SearchAccounts :many
|
||||||
SELECT accounts.id, accounts.budget_id, accounts.name FROM accounts
|
SELECT accounts.id, accounts.budget_id, accounts.name, true as is_account FROM accounts
|
||||||
WHERE accounts.budget_id = @budget_id
|
WHERE accounts.budget_id = @budget_id
|
||||||
AND accounts.name LIKE @search
|
AND accounts.name LIKE @search
|
||||||
ORDER BY accounts.name;
|
ORDER BY accounts.name;
|
@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@ -14,8 +15,9 @@ import (
|
|||||||
type NewTransactionPayload struct {
|
type NewTransactionPayload struct {
|
||||||
Date JSONDate `json:"date"`
|
Date JSONDate `json:"date"`
|
||||||
Payee struct {
|
Payee struct {
|
||||||
ID uuid.NullUUID
|
ID uuid.NullUUID
|
||||||
Name string
|
Name string
|
||||||
|
IsAccount bool
|
||||||
} `json:"payee"`
|
} `json:"payee"`
|
||||||
Category struct {
|
Category struct {
|
||||||
ID uuid.NullUUID
|
ID uuid.NullUUID
|
||||||
@ -36,39 +38,42 @@ func (h *Handler) newTransaction(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
amount := numeric.Numeric{}
|
amount, err := numeric.Parse(payload.Amount)
|
||||||
err = amount.Set(payload.Amount)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("amount: %w", err))
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("amount: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payeeID := payload.Payee.ID
|
newTransaction := postgres.CreateTransactionParams{
|
||||||
if !payeeID.Valid && payload.Payee.Name != "" {
|
Memo: payload.Memo,
|
||||||
newPayee := postgres.CreatePayeeParams{
|
Date: time.Time(payload.Date),
|
||||||
Name: payload.Payee.Name,
|
Amount: amount,
|
||||||
BudgetID: payload.BudgetID,
|
Status: postgres.TransactionStatus(payload.State),
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Payee.IsAccount {
|
||||||
|
newTransaction.GroupID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
|
||||||
|
newTransaction.Amount = amount.Neg()
|
||||||
|
newTransaction.AccountID = payload.Payee.ID.UUID
|
||||||
|
newTransaction.CategoryID = uuid.NullUUID{}
|
||||||
|
|
||||||
|
_, err = h.Service.CreateTransaction(c.Request.Context(), newTransaction)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transfer transaction: %w", err))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
payee, err := h.Service.CreatePayee(c.Request.Context(), newPayee)
|
|
||||||
|
newTransaction.Amount = amount
|
||||||
|
} else {
|
||||||
|
payeeID, err := GetPayeeID(c.Request.Context(), payload, h)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create payee: %w", err))
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create payee: %w", err))
|
||||||
}
|
}
|
||||||
|
newTransaction.PayeeID = payeeID
|
||||||
payeeID = uuid.NullUUID{
|
|
||||||
UUID: payee.ID,
|
|
||||||
Valid: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newTransaction := postgres.CreateTransactionParams{
|
newTransaction.CategoryID = payload.Category.ID
|
||||||
Memo: payload.Memo,
|
newTransaction.AccountID = payload.AccountID
|
||||||
Date: time.Time(payload.Date),
|
|
||||||
Amount: amount,
|
|
||||||
AccountID: payload.AccountID,
|
|
||||||
PayeeID: payeeID,
|
|
||||||
CategoryID: payload.Category.ID,
|
|
||||||
Status: postgres.TransactionStatus(payload.State),
|
|
||||||
}
|
|
||||||
transaction, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
|
transaction, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
|
||||||
@ -77,3 +82,25 @@ func (h *Handler) newTransaction(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, transaction)
|
c.JSON(http.StatusOK, transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPayeeID(context context.Context, payload NewTransactionPayload, h *Handler) (uuid.NullUUID, error) {
|
||||||
|
payeeID := payload.Payee.ID
|
||||||
|
if payeeID.Valid {
|
||||||
|
return payeeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Payee.Name == "" {
|
||||||
|
return uuid.NullUUID{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newPayee := postgres.CreatePayeeParams{
|
||||||
|
Name: payload.Payee.Name,
|
||||||
|
BudgetID: payload.BudgetID,
|
||||||
|
}
|
||||||
|
payee, err := h.Service.CreatePayee(context, newPayee)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, fmt.Errorf("create payee: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
8
web/src/components/Button.vue
Normal file
8
web/src/components/Button.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button class="px-4 py-2 text-base font-medium rounded-md shadow-sm focus:outline-none focus:ring-2">
|
||||||
|
<slot></slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
@ -5,11 +5,6 @@ import { useBudgetsStore } from "../stores/budget"
|
|||||||
import { useAccountStore } from "../stores/budget-account"
|
import { useAccountStore } from "../stores/budget-account"
|
||||||
import { useSettingsStore } from "../stores/settings"
|
import { useSettingsStore } from "../stores/settings"
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
budgetid: string,
|
|
||||||
accountid: string,
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const ExpandMenu = computed(() => useSettingsStore().Menu.Expand);
|
const ExpandMenu = computed(() => useSettingsStore().Menu.Expand);
|
||||||
|
|
||||||
const budgetStore = useBudgetsStore();
|
const budgetStore = useBudgetsStore();
|
||||||
@ -30,7 +25,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
|
|||||||
{{CurrentBudgetName}}
|
{{CurrentBudgetName}}
|
||||||
</span>
|
</span>
|
||||||
<span class="bg-orange-200 rounded-lg m-1 p-1 px-3 flex flex-col">
|
<span class="bg-orange-200 rounded-lg m-1 p-1 px-3 flex flex-col">
|
||||||
<router-link :to="'/budget/'+budgetid+'/budgeting'">Budget</router-link><br />
|
<router-link :to="'/budget/'+CurrentBudgetID+'/budgeting'">Budget</router-link><br />
|
||||||
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
|
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
|
||||||
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
|
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
|
||||||
</span>
|
</span>
|
||||||
@ -40,7 +35,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
|
|||||||
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" />
|
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" />
|
||||||
</div>
|
</div>
|
||||||
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between">
|
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between">
|
||||||
<router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link>
|
<router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{account.Name}}</router-link>
|
||||||
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
|
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@ -50,7 +45,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
|
|||||||
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" />
|
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" />
|
||||||
</div>
|
</div>
|
||||||
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between">
|
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between">
|
||||||
<router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link>
|
<router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{account.Name}}</router-link>
|
||||||
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
|
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
@ -5,6 +5,7 @@ import { DELETE, POST } from "../api";
|
|||||||
import { useBudgetsStore } from "../stores/budget";
|
import { useBudgetsStore } from "../stores/budget";
|
||||||
import { useSessionStore } from "../stores/session";
|
import { useSessionStore } from "../stores/session";
|
||||||
import Card from "../components/Card.vue";
|
import Card from "../components/Card.vue";
|
||||||
|
import Button from "../components/Button.vue";
|
||||||
|
|
||||||
const transactionsFile = ref<File | undefined>(undefined);
|
const transactionsFile = ref<File | undefined>(undefined);
|
||||||
const assignmentsFile = ref<File | undefined>(undefined);
|
const assignmentsFile = ref<File | undefined>(undefined);
|
||||||
@ -57,37 +58,41 @@ function ynabImport() {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1>Danger Zone</h1>
|
<h1>Danger Zone</h1>
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card class="flex-col p-3">
|
||||||
<h2>Clear Budget</h2>
|
<h2 class="text-lg font-bold">Clear Budget</h2>
|
||||||
<p>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</p>
|
<p>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</p>
|
||||||
|
|
||||||
<button @click="clearBudget">Clear budget</button>
|
<Button class="bg-red-500" @click="clearBudget">Clear budget</Button>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card class="flex-col p-3">
|
||||||
<h2>Delete Budget</h2>
|
<h2 class="text-lg font-bold">Delete Budget</h2>
|
||||||
<p>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</p>
|
<p>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</p>
|
||||||
<button @click="deleteBudget">Delete budget</button>
|
<Button class="bg-red-500" @click="deleteBudget">Delete budget</button>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card class="flex-col p-3">
|
||||||
<h2>Fix all historic negative category-balances</h2>
|
<h2 class="text-lg font-bold">Fix all historic negative category-balances</h2>
|
||||||
<p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p>
|
<p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p>
|
||||||
<button @click="cleanNegative">Fix negative</button>
|
<Button class="bg-orange-500" @click="cleanNegative">Fix negative</button>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card class="flex-col p-3">
|
||||||
<h2>Import YNAB Budget</h2>
|
<h2 class="text-lg font-bold">Import YNAB Budget</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<div>
|
||||||
|
<label for="transactions_file">
|
||||||
|
Transaktionen:
|
||||||
|
<input type="file" @change="gotTransactions" accept="text/*" />
|
||||||
|
</label>
|
||||||
|
<br />
|
||||||
|
<label for="assignments_file">
|
||||||
|
Budget:
|
||||||
|
<input type="file" @change="gotAssignments" accept="text/*" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="transactions_file">
|
<Button class="bg-blue-500" :disabled="filesIncomplete" @click="ynabImport">Importieren</Button>
|
||||||
Transaktionen:
|
</div>
|
||||||
<input type="file" @change="gotTransactions" accept="text/*" />
|
|
||||||
</label>
|
|
||||||
<br />
|
|
||||||
<label for="assignments_file">
|
|
||||||
Budget:
|
|
||||||
<input type="file" @change="gotAssignments" accept="text/*" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button :disabled="filesIncomplete" @click="ynabImport">Importieren</button>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -36,8 +36,10 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.Session = {
|
this.Session = {
|
||||||
User: x.User,
|
User: x.User,
|
||||||
Token: x.Token,
|
Token: x.Token,
|
||||||
},
|
}
|
||||||
this.Budgets = x.Budgets;
|
for (const budget of x.Budgets) {
|
||||||
|
this.Budgets.set(budget.ID, budget);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async login(login: any) {
|
async login(login: any) {
|
||||||
const response = await POST("/user/login", JSON.stringify(login));
|
const response = await POST("/user/login", JSON.stringify(login));
|
||||||
|
Loading…
x
Reference in New Issue
Block a user