Compare commits
51 Commits
Author | SHA1 | Date | |
---|---|---|---|
d8a96535dc | |||
ce26e76e8f | |||
6b3ac199fc | |||
4c6d21c2b4 | |||
faef975f1a | |||
bd686e0c00 | |||
4844889e0b | |||
2775578713 | |||
27dd6e923c | |||
2cf6b815bf | |||
81aacf339e | |||
16bcf516f6 | |||
52503a4c92 | |||
b4321395d9 | |||
d7058a49b0 | |||
eb9fc722aa | |||
511081298e | |||
422a74704b | |||
c4995bcbaf | |||
79fd95e152 | |||
af8fda178d | |||
71f4d2384b | |||
eb52bd5def | |||
adcb64f4ad | |||
a73c5ce04b | |||
52bb343402 | |||
27ce8d250f | |||
408207df92 | |||
98890f10eb | |||
5621d63436 | |||
05099e469f | |||
ae9e9d34c9 | |||
32439e3e87 | |||
4ed15b740b | |||
5f161b2163 | |||
10ea73663f | |||
07804e4241 | |||
2f4f8a7568 | |||
13d0194632 | |||
c864666eb6 | |||
464931babe | |||
480a95e096 | |||
2d37ec147c | |||
75b48be20d | |||
306edbf817 | |||
be3829baf8 | |||
a452482381 | |||
0f6990407d | |||
97be5abc8c | |||
1e80ba6ca8 | |||
1331304639 |
36
.drone.yml
36
.drone.yml
@ -4,11 +4,23 @@ type: docker
|
||||
name: budgeteer
|
||||
|
||||
steps:
|
||||
- name: Taskfile.dev
|
||||
- name: Taskfile.dev PR
|
||||
image: hub.javil.eu/budgeteer:dev
|
||||
pull: true
|
||||
commands:
|
||||
- task ci
|
||||
when:
|
||||
event:
|
||||
- pull_request
|
||||
|
||||
- name: Taskfile.dev
|
||||
image: hub.javil.eu/budgeteer:dev
|
||||
pull: always
|
||||
commands:
|
||||
- task ci
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
- name: docker
|
||||
image: plugins/docker
|
||||
@ -24,10 +36,26 @@ steps:
|
||||
tags:
|
||||
- latest
|
||||
when:
|
||||
branch:
|
||||
- master
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
- push
|
||||
|
||||
- name: docker tag
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: hub.javil.eu
|
||||
username:
|
||||
from_secret: docker_user
|
||||
password:
|
||||
from_secret: docker_password
|
||||
repo: hub.javil.eu/budgeteer
|
||||
context: build
|
||||
dockerfile: build/Dockerfile
|
||||
auto_tag: true
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
image_pull_secrets:
|
||||
- hub.javil.eu
|
@ -9,8 +9,9 @@ RUN apk --no-cache add go nodejs yarn bash curl git git-perl tmux
|
||||
ADD docker/build.sh /
|
||||
RUN yarn global add @vue/cli
|
||||
ENV PATH="/root/.yarn/bin/:${PATH}"
|
||||
WORKDIR /src
|
||||
WORKDIR /src/web
|
||||
ADD web/package.json web/yarn.lock /src/web/
|
||||
RUN yarn
|
||||
WORKDIR /src
|
||||
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
|
||||
CMD /build.sh
|
||||
|
@ -86,11 +86,12 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
|
||||
}
|
||||
|
||||
const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many
|
||||
SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
|
||||
SELECT accounts.id, accounts.name, accounts.on_budget,
|
||||
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW())::decimal(12,2) as working_balance,
|
||||
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status IN ('Cleared', 'Reconciled'))::decimal(12,2) as cleared_balance,
|
||||
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status = 'Reconciled')::decimal(12,2) as reconciled_balance
|
||||
FROM accounts
|
||||
LEFT JOIN transactions ON transactions.account_id = accounts.id AND transactions.date < NOW()
|
||||
WHERE accounts.budget_id = $1
|
||||
GROUP BY accounts.id, accounts.name
|
||||
ORDER BY accounts.name
|
||||
`
|
||||
|
||||
@ -98,7 +99,9 @@ type GetAccountsWithBalanceRow struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
OnBudget bool
|
||||
Balance numeric.Numeric
|
||||
WorkingBalance numeric.Numeric
|
||||
ClearedBalance numeric.Numeric
|
||||
ReconciledBalance numeric.Numeric
|
||||
}
|
||||
|
||||
func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID) ([]GetAccountsWithBalanceRow, error) {
|
||||
@ -114,7 +117,9 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.OnBudget,
|
||||
&i.Balance,
|
||||
&i.WorkingBalance,
|
||||
&i.ClearedBalance,
|
||||
&i.ReconciledBalance,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -130,7 +135,7 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
|
||||
}
|
||||
|
||||
const searchAccounts = `-- name: SearchAccounts :many
|
||||
SELECT accounts.id, accounts.budget_id, accounts.name, true as is_account FROM accounts
|
||||
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
|
||||
WHERE accounts.budget_id = $1
|
||||
AND accounts.name LIKE $2
|
||||
ORDER BY accounts.name
|
||||
@ -145,7 +150,7 @@ type SearchAccountsRow struct {
|
||||
ID uuid.UUID
|
||||
BudgetID uuid.UUID
|
||||
Name string
|
||||
IsAccount bool
|
||||
Type interface{}
|
||||
}
|
||||
|
||||
func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams) ([]SearchAccountsRow, error) {
|
||||
@ -161,7 +166,7 @@ func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams)
|
||||
&i.ID,
|
||||
&i.BudgetID,
|
||||
&i.Name,
|
||||
&i.IsAccount,
|
||||
&i.Type,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -118,7 +118,8 @@ func (q *Queries) GetCategoryGroups(ctx context.Context, budgetID uuid.UUID) ([]
|
||||
}
|
||||
|
||||
const searchCategories = `-- name: SearchCategories :many
|
||||
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
|
||||
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type
|
||||
FROM categories
|
||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
||||
WHERE category_groups.budget_id = $1
|
||||
AND categories.name LIKE $2
|
||||
@ -133,6 +134,7 @@ type SearchCategoriesParams struct {
|
||||
type SearchCategoriesRow struct {
|
||||
Name interface{}
|
||||
ID uuid.UUID
|
||||
Type interface{}
|
||||
}
|
||||
|
||||
func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesParams) ([]SearchCategoriesRow, error) {
|
||||
@ -144,7 +146,7 @@ func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesPara
|
||||
var items []SearchCategoriesRow
|
||||
for rows.Next() {
|
||||
var i SearchCategoriesRow
|
||||
if err := rows.Scan(&i.Name, &i.ID); err != nil {
|
||||
if err := rows.Scan(&i.Name, &i.ID, &i.Type); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
|
@ -72,6 +72,24 @@ type CategoryGroup struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type DisplayTransaction struct {
|
||||
ID uuid.UUID
|
||||
Date time.Time
|
||||
Memo string
|
||||
Amount numeric.Numeric
|
||||
GroupID uuid.NullUUID
|
||||
Status TransactionStatus
|
||||
Account string
|
||||
PayeeID uuid.NullUUID
|
||||
CategoryID uuid.NullUUID
|
||||
Payee string
|
||||
CategoryGroup string
|
||||
Category string
|
||||
TransferAccount string
|
||||
BudgetID uuid.UUID
|
||||
AccountID uuid.UUID
|
||||
}
|
||||
|
||||
type Payee struct {
|
||||
ID uuid.UUID
|
||||
BudgetID uuid.UUID
|
||||
|
@ -58,7 +58,7 @@ func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, e
|
||||
}
|
||||
|
||||
const searchPayees = `-- name: SearchPayees :many
|
||||
SELECT payees.id, payees.budget_id, payees.name FROM payees
|
||||
SELECT payees.id, payees.budget_id, payees.name, 'payee' as type FROM payees
|
||||
WHERE payees.budget_id = $1
|
||||
AND payees.name LIKE $2
|
||||
ORDER BY payees.name
|
||||
@ -69,16 +69,28 @@ type SearchPayeesParams struct {
|
||||
Search string
|
||||
}
|
||||
|
||||
func (q *Queries) SearchPayees(ctx context.Context, arg SearchPayeesParams) ([]Payee, error) {
|
||||
type SearchPayeesRow struct {
|
||||
ID uuid.UUID
|
||||
BudgetID uuid.UUID
|
||||
Name string
|
||||
Type interface{}
|
||||
}
|
||||
|
||||
func (q *Queries) SearchPayees(ctx context.Context, arg SearchPayeesParams) ([]SearchPayeesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, searchPayees, arg.BudgetID, arg.Search)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Payee
|
||||
var items []SearchPayeesRow
|
||||
for rows.Next() {
|
||||
var i Payee
|
||||
if err := rows.Scan(&i.ID, &i.BudgetID, &i.Name); err != nil {
|
||||
var i SearchPayeesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.BudgetID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
|
@ -14,15 +14,16 @@ WHERE accounts.budget_id = $1
|
||||
ORDER BY accounts.name;
|
||||
|
||||
-- name: GetAccountsWithBalance :many
|
||||
SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
|
||||
SELECT accounts.id, accounts.name, accounts.on_budget,
|
||||
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW())::decimal(12,2) as working_balance,
|
||||
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status IN ('Cleared', 'Reconciled'))::decimal(12,2) as cleared_balance,
|
||||
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status = 'Reconciled')::decimal(12,2) as reconciled_balance
|
||||
FROM accounts
|
||||
LEFT JOIN transactions ON transactions.account_id = accounts.id AND transactions.date < NOW()
|
||||
WHERE accounts.budget_id = $1
|
||||
GROUP BY accounts.id, accounts.name
|
||||
ORDER BY accounts.name;
|
||||
|
||||
-- name: SearchAccounts :many
|
||||
SELECT accounts.id, accounts.budget_id, accounts.name, true as is_account FROM accounts
|
||||
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
|
||||
WHERE accounts.budget_id = @budget_id
|
||||
AND accounts.name LIKE @search
|
||||
ORDER BY accounts.name;
|
||||
|
@ -21,7 +21,8 @@ WHERE category_groups.budget_id = $1
|
||||
ORDER BY category_groups.name, categories.name;
|
||||
|
||||
-- name: SearchCategories :many
|
||||
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
|
||||
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type
|
||||
FROM categories
|
||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
||||
WHERE category_groups.budget_id = @budget_id
|
||||
AND categories.name LIKE @search
|
||||
|
@ -10,7 +10,7 @@ WHERE payees.budget_id = $1
|
||||
ORDER BY name;
|
||||
|
||||
-- name: SearchPayees :many
|
||||
SELECT payees.* FROM payees
|
||||
SELECT payees.*, 'payee' as type FROM payees
|
||||
WHERE payees.budget_id = @budget_id
|
||||
AND payees.name LIKE @search
|
||||
ORDER BY payees.name;
|
||||
|
@ -1,70 +1,40 @@
|
||||
-- name: GetTransaction :one
|
||||
SELECT * FROM transactions
|
||||
SELECT * FROM display_transactions
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateTransaction :one
|
||||
INSERT INTO transactions
|
||||
(date, memo, amount, account_id, payee_id, category_id, group_id, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
RETURNING id;
|
||||
|
||||
-- name: UpdateTransaction :exec
|
||||
UPDATE transactions
|
||||
SET date = $1,
|
||||
memo = $2,
|
||||
amount = $3,
|
||||
account_id = $4,
|
||||
payee_id = $5,
|
||||
category_id = $6
|
||||
WHERE id = $7;
|
||||
payee_id = $4,
|
||||
category_id = $5
|
||||
WHERE id = $6;
|
||||
|
||||
-- name: SetTransactionReconciled :exec
|
||||
UPDATE transactions
|
||||
SET status = 'Reconciled'
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: DeleteTransaction :exec
|
||||
DELETE FROM transactions
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetAllTransactionsForBudget :many
|
||||
SELECT transactions.id, transactions.date, transactions.memo,
|
||||
transactions.amount, transactions.group_id, transactions.status,
|
||||
accounts.name as account,
|
||||
COALESCE(payees.name, '') as payee,
|
||||
COALESCE(category_groups.name, '') as category_group,
|
||||
COALESCE(categories.name, '') as category,
|
||||
COALESCE((
|
||||
SELECT CONCAT(otherAccounts.name)
|
||||
FROM transactions otherTransactions
|
||||
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
|
||||
WHERE otherTransactions.group_id = transactions.group_id
|
||||
AND otherTransactions.id != transactions.id
|
||||
), '')::text as transfer_account
|
||||
FROM transactions
|
||||
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||
LEFT JOIN payees ON payees.id = transactions.payee_id
|
||||
LEFT JOIN categories ON categories.id = transactions.category_id
|
||||
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
|
||||
WHERE accounts.budget_id = $1
|
||||
ORDER BY transactions.date DESC;
|
||||
SELECT t.*
|
||||
FROM display_transactions AS t
|
||||
WHERE t.budget_id = $1;
|
||||
|
||||
-- name: GetTransactionsForAccount :many
|
||||
SELECT transactions.id, transactions.date, transactions.memo,
|
||||
transactions.amount, transactions.group_id, transactions.status,
|
||||
accounts.name as account,
|
||||
COALESCE(payees.name, '') as payee,
|
||||
COALESCE(category_groups.name, '') as category_group,
|
||||
COALESCE(categories.name, '') as category,
|
||||
COALESCE((
|
||||
SELECT CONCAT(otherAccounts.name)
|
||||
FROM transactions otherTransactions
|
||||
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
|
||||
WHERE otherTransactions.group_id = transactions.group_id
|
||||
AND otherTransactions.id != transactions.id
|
||||
), '')::text as transfer_account
|
||||
FROM transactions
|
||||
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||
LEFT JOIN payees ON payees.id = transactions.payee_id
|
||||
LEFT JOIN categories ON categories.id = transactions.category_id
|
||||
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
|
||||
WHERE transactions.account_id = $1
|
||||
ORDER BY transactions.date DESC
|
||||
SELECT t.*
|
||||
FROM display_transactions AS t
|
||||
WHERE t.account_id = $1
|
||||
LIMIT 200;
|
||||
|
||||
-- name: DeleteAllTransactions :execrows
|
||||
|
25
postgres/schema/0015_transactions-view.sql
Normal file
25
postgres/schema/0015_transactions-view.sql
Normal file
@ -0,0 +1,25 @@
|
||||
-- +goose Up
|
||||
CREATE VIEW display_transactions AS
|
||||
SELECT transactions.id, transactions.date, transactions.memo,
|
||||
transactions.amount, transactions.group_id, transactions.status,
|
||||
accounts.name as account, transactions.payee_id, transactions.category_id,
|
||||
COALESCE(payees.name, '') as payee,
|
||||
COALESCE(category_groups.name, '') as category_group,
|
||||
COALESCE(categories.name, '') as category,
|
||||
COALESCE((
|
||||
SELECT CONCAT(otherAccounts.name)
|
||||
FROM transactions otherTransactions
|
||||
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
|
||||
WHERE otherTransactions.group_id = transactions.group_id
|
||||
AND otherTransactions.id != transactions.id
|
||||
), '')::text as transfer_account,
|
||||
accounts.budget_id, transactions.account_id
|
||||
FROM transactions
|
||||
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||
LEFT JOIN payees ON payees.id = transactions.payee_id
|
||||
LEFT JOIN categories ON categories.id = transactions.category_id
|
||||
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
|
||||
ORDER BY transactions.date DESC;
|
||||
|
||||
-- +goose Down
|
||||
DROP VIEW display_transactions;
|
@ -15,7 +15,7 @@ const createTransaction = `-- name: CreateTransaction :one
|
||||
INSERT INTO transactions
|
||||
(date, memo, amount, account_id, payee_id, category_id, group_id, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id, status
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type CreateTransactionParams struct {
|
||||
@ -29,7 +29,7 @@ type CreateTransactionParams struct {
|
||||
Status TransactionStatus
|
||||
}
|
||||
|
||||
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) {
|
||||
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, createTransaction,
|
||||
arg.Date,
|
||||
arg.Memo,
|
||||
@ -40,19 +40,9 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
|
||||
arg.GroupID,
|
||||
arg.Status,
|
||||
)
|
||||
var i Transaction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Date,
|
||||
&i.Memo,
|
||||
&i.Amount,
|
||||
&i.AccountID,
|
||||
&i.CategoryID,
|
||||
&i.PayeeID,
|
||||
&i.GroupID,
|
||||
&i.Status,
|
||||
)
|
||||
return i, err
|
||||
var id uuid.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const deleteAllTransactions = `-- name: DeleteAllTransactions :execrows
|
||||
@ -81,51 +71,20 @@ func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error {
|
||||
}
|
||||
|
||||
const getAllTransactionsForBudget = `-- name: GetAllTransactionsForBudget :many
|
||||
SELECT transactions.id, transactions.date, transactions.memo,
|
||||
transactions.amount, transactions.group_id, transactions.status,
|
||||
accounts.name as account,
|
||||
COALESCE(payees.name, '') as payee,
|
||||
COALESCE(category_groups.name, '') as category_group,
|
||||
COALESCE(categories.name, '') as category,
|
||||
COALESCE((
|
||||
SELECT CONCAT(otherAccounts.name)
|
||||
FROM transactions otherTransactions
|
||||
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
|
||||
WHERE otherTransactions.group_id = transactions.group_id
|
||||
AND otherTransactions.id != transactions.id
|
||||
), '')::text as transfer_account
|
||||
FROM transactions
|
||||
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||
LEFT JOIN payees ON payees.id = transactions.payee_id
|
||||
LEFT JOIN categories ON categories.id = transactions.category_id
|
||||
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
|
||||
WHERE accounts.budget_id = $1
|
||||
ORDER BY transactions.date DESC
|
||||
SELECT t.id, t.date, t.memo, t.amount, t.group_id, t.status, t.account, t.payee_id, t.category_id, t.payee, t.category_group, t.category, t.transfer_account, t.budget_id, t.account_id
|
||||
FROM display_transactions AS t
|
||||
WHERE t.budget_id = $1
|
||||
`
|
||||
|
||||
type GetAllTransactionsForBudgetRow struct {
|
||||
ID uuid.UUID
|
||||
Date time.Time
|
||||
Memo string
|
||||
Amount numeric.Numeric
|
||||
GroupID uuid.NullUUID
|
||||
Status TransactionStatus
|
||||
Account string
|
||||
Payee string
|
||||
CategoryGroup string
|
||||
Category string
|
||||
TransferAccount string
|
||||
}
|
||||
|
||||
func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid.UUID) ([]GetAllTransactionsForBudgetRow, error) {
|
||||
func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid.UUID) ([]DisplayTransaction, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAllTransactionsForBudget, budgetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAllTransactionsForBudgetRow
|
||||
var items []DisplayTransaction
|
||||
for rows.Next() {
|
||||
var i GetAllTransactionsForBudgetRow
|
||||
var i DisplayTransaction
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Date,
|
||||
@ -134,10 +93,14 @@ func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid
|
||||
&i.GroupID,
|
||||
&i.Status,
|
||||
&i.Account,
|
||||
&i.PayeeID,
|
||||
&i.CategoryID,
|
||||
&i.Payee,
|
||||
&i.CategoryGroup,
|
||||
&i.Category,
|
||||
&i.TransferAccount,
|
||||
&i.BudgetID,
|
||||
&i.AccountID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -153,23 +116,29 @@ func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid
|
||||
}
|
||||
|
||||
const getTransaction = `-- name: GetTransaction :one
|
||||
SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id, status FROM transactions
|
||||
SELECT id, date, memo, amount, group_id, status, account, payee_id, category_id, payee, category_group, category, transfer_account, budget_id, account_id FROM display_transactions
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (Transaction, error) {
|
||||
func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (DisplayTransaction, error) {
|
||||
row := q.db.QueryRowContext(ctx, getTransaction, id)
|
||||
var i Transaction
|
||||
var i DisplayTransaction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Date,
|
||||
&i.Memo,
|
||||
&i.Amount,
|
||||
&i.AccountID,
|
||||
&i.CategoryID,
|
||||
&i.PayeeID,
|
||||
&i.GroupID,
|
||||
&i.Status,
|
||||
&i.Account,
|
||||
&i.PayeeID,
|
||||
&i.CategoryID,
|
||||
&i.Payee,
|
||||
&i.CategoryGroup,
|
||||
&i.Category,
|
||||
&i.TransferAccount,
|
||||
&i.BudgetID,
|
||||
&i.AccountID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@ -209,52 +178,21 @@ func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetI
|
||||
}
|
||||
|
||||
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
|
||||
SELECT transactions.id, transactions.date, transactions.memo,
|
||||
transactions.amount, transactions.group_id, transactions.status,
|
||||
accounts.name as account,
|
||||
COALESCE(payees.name, '') as payee,
|
||||
COALESCE(category_groups.name, '') as category_group,
|
||||
COALESCE(categories.name, '') as category,
|
||||
COALESCE((
|
||||
SELECT CONCAT(otherAccounts.name)
|
||||
FROM transactions otherTransactions
|
||||
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
|
||||
WHERE otherTransactions.group_id = transactions.group_id
|
||||
AND otherTransactions.id != transactions.id
|
||||
), '')::text as transfer_account
|
||||
FROM transactions
|
||||
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||
LEFT JOIN payees ON payees.id = transactions.payee_id
|
||||
LEFT JOIN categories ON categories.id = transactions.category_id
|
||||
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
|
||||
WHERE transactions.account_id = $1
|
||||
ORDER BY transactions.date DESC
|
||||
SELECT t.id, t.date, t.memo, t.amount, t.group_id, t.status, t.account, t.payee_id, t.category_id, t.payee, t.category_group, t.category, t.transfer_account, t.budget_id, t.account_id
|
||||
FROM display_transactions AS t
|
||||
WHERE t.account_id = $1
|
||||
LIMIT 200
|
||||
`
|
||||
|
||||
type GetTransactionsForAccountRow struct {
|
||||
ID uuid.UUID
|
||||
Date time.Time
|
||||
Memo string
|
||||
Amount numeric.Numeric
|
||||
GroupID uuid.NullUUID
|
||||
Status TransactionStatus
|
||||
Account string
|
||||
Payee string
|
||||
CategoryGroup string
|
||||
Category string
|
||||
TransferAccount string
|
||||
}
|
||||
|
||||
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) {
|
||||
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]DisplayTransaction, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getTransactionsForAccount, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetTransactionsForAccountRow
|
||||
var items []DisplayTransaction
|
||||
for rows.Next() {
|
||||
var i GetTransactionsForAccountRow
|
||||
var i DisplayTransaction
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Date,
|
||||
@ -263,10 +201,14 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
|
||||
&i.GroupID,
|
||||
&i.Status,
|
||||
&i.Account,
|
||||
&i.PayeeID,
|
||||
&i.CategoryID,
|
||||
&i.Payee,
|
||||
&i.CategoryGroup,
|
||||
&i.Category,
|
||||
&i.TransferAccount,
|
||||
&i.BudgetID,
|
||||
&i.AccountID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -281,22 +223,31 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setTransactionReconciled = `-- name: SetTransactionReconciled :exec
|
||||
UPDATE transactions
|
||||
SET status = 'Reconciled'
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) SetTransactionReconciled(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, setTransactionReconciled, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateTransaction = `-- name: UpdateTransaction :exec
|
||||
UPDATE transactions
|
||||
SET date = $1,
|
||||
memo = $2,
|
||||
amount = $3,
|
||||
account_id = $4,
|
||||
payee_id = $5,
|
||||
category_id = $6
|
||||
WHERE id = $7
|
||||
payee_id = $4,
|
||||
category_id = $5
|
||||
WHERE id = $6
|
||||
`
|
||||
|
||||
type UpdateTransactionParams struct {
|
||||
Date time.Time
|
||||
Memo string
|
||||
Amount numeric.Numeric
|
||||
AccountID uuid.UUID
|
||||
PayeeID uuid.NullUUID
|
||||
CategoryID uuid.NullUUID
|
||||
ID uuid.UUID
|
||||
@ -307,7 +258,6 @@ func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionPa
|
||||
arg.Date,
|
||||
arg.Memo,
|
||||
arg.Amount,
|
||||
arg.AccountID,
|
||||
arg.PayeeID,
|
||||
arg.CategoryID,
|
||||
arg.ID,
|
||||
|
@ -110,7 +110,7 @@ func (ynab *YNABExport) ExportTransactions(context context.Context, w io.Writer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetTransactionRow(transaction GetAllTransactionsForBudgetRow) []string {
|
||||
func GetTransactionRow(transaction DisplayTransaction) []string {
|
||||
row := []string{
|
||||
transaction.Account,
|
||||
"", // Flag
|
||||
|
@ -33,7 +33,7 @@ func (h *Handler) transactionsForAccount(c *gin.Context) {
|
||||
|
||||
type TransactionsResponse struct {
|
||||
Account postgres.Account
|
||||
Transactions []postgres.GetTransactionsForAccountRow
|
||||
Transactions []postgres.DisplayTransaction
|
||||
}
|
||||
|
||||
type EditAccountRequest struct {
|
||||
|
@ -36,44 +36,42 @@ type ErrorResponse struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
type SuccessResponse struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
// LoadRoutes initializes all the routes.
|
||||
func (h *Handler) LoadRoutes(router *gin.Engine) {
|
||||
router.Use(enableCachingForStaticFiles())
|
||||
router.NoRoute(h.ServeStatic)
|
||||
|
||||
withLogin := router.Group("")
|
||||
withLogin.Use(h.verifyLoginWithRedirect)
|
||||
|
||||
withBudget := router.Group("")
|
||||
withBudget.Use(h.verifyLoginWithForbidden)
|
||||
withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
|
||||
withBudget.GET("/budget/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
|
||||
unauthenticated := api.Group("/user")
|
||||
unauthenticated.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
|
||||
unauthenticated.POST("/login", h.loginPost)
|
||||
unauthenticated.POST("/register", h.registerPost)
|
||||
anonymous := api.Group("/user")
|
||||
anonymous.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
|
||||
anonymous.POST("/login", h.loginPost)
|
||||
anonymous.POST("/register", h.registerPost)
|
||||
|
||||
authenticated := api.Group("")
|
||||
authenticated.Use(h.verifyLoginWithForbidden)
|
||||
authenticated.GET("/dashboard", h.dashboard)
|
||||
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
|
||||
authenticated.POST("/account/:accountid/reconcile", h.reconcileTransactions)
|
||||
authenticated.POST("/account/:accountid", h.editAccount)
|
||||
authenticated.GET("/admin/clear-database", h.clearDatabase)
|
||||
authenticated.GET("/budget/:budgetid", h.budgeting)
|
||||
authenticated.GET("/budget/:budgetid/:year/:month", h.budgetingForMonth)
|
||||
authenticated.GET("/budget/:budgetid/autocomplete/payees", h.autocompletePayee)
|
||||
authenticated.GET("/budget/:budgetid/autocomplete/categories", h.autocompleteCategories)
|
||||
authenticated.DELETE("/budget/:budgetid", h.deleteBudget)
|
||||
authenticated.POST("/budget/:budgetid/import/ynab", h.importYNAB)
|
||||
authenticated.POST("/budget/:budgetid/export/ynab/transactions", h.exportYNABTransactions)
|
||||
authenticated.POST("/budget/:budgetid/export/ynab/assignments", h.exportYNABAssignments)
|
||||
authenticated.POST("/budget/:budgetid/settings/clear", h.clearBudget)
|
||||
|
||||
budget := authenticated.Group("/budget")
|
||||
budget.POST("/new", h.newBudget)
|
||||
budget.GET("/:budgetid", h.budgeting)
|
||||
budget.GET("/:budgetid/:year/:month", h.budgetingForMonth)
|
||||
budget.GET("/:budgetid/autocomplete/payees", h.autocompletePayee)
|
||||
budget.GET("/:budgetid/autocomplete/categories", h.autocompleteCategories)
|
||||
budget.DELETE("/:budgetid", h.deleteBudget)
|
||||
budget.POST("/:budgetid/import/ynab", h.importYNAB)
|
||||
budget.POST("/:budgetid/export/ynab/transactions", h.exportYNABTransactions)
|
||||
budget.POST("/:budgetid/export/ynab/assignments", h.exportYNABAssignments)
|
||||
budget.POST("/:budgetid/settings/clear", h.clearBudget)
|
||||
budget.POST("/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
|
||||
|
||||
transaction := authenticated.Group("/transaction")
|
||||
transaction.POST("/new", h.newTransaction)
|
||||
|
103
server/reconcile.go
Normal file
103
server/reconcile.go
Normal file
@ -0,0 +1,103 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ReconcileTransactionsRequest struct {
|
||||
TransactionIDs []uuid.UUID `json:"transactionIds"`
|
||||
ReconcilationTransactionAmount string `json:"reconciliationTransactionAmount"`
|
||||
}
|
||||
|
||||
type ReconcileTransactionsResponse struct {
|
||||
Message string
|
||||
ReconciliationTransaction *postgres.DisplayTransaction
|
||||
}
|
||||
|
||||
func (h *Handler) reconcileTransactions(c *gin.Context) {
|
||||
accountID := c.Param("accountid")
|
||||
accountUUID, err := uuid.Parse(accountID)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
var request ReconcileTransactionsRequest
|
||||
err = c.BindJSON(&request)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("parse request: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var amount numeric.Numeric
|
||||
err = amount.Set(request.ReconcilationTransactionAmount)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("parse request: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.Service.BeginTx(c.Request.Context(), &sql.TxOptions{})
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("begin tx: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
db := h.Service.WithTx(tx)
|
||||
for _, transactionID := range request.TransactionIDs {
|
||||
err := db.SetTransactionReconciled(c.Request.Context(), transactionID)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update transaction: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reconciliationTransaction, err := h.CreateReconcilationTransaction(amount, accountUUID, db, c)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("insert new transaction: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("commit: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ReconcileTransactionsResponse{
|
||||
Message: fmt.Sprintf("Set status for %d transactions", len(request.TransactionIDs)),
|
||||
ReconciliationTransaction: reconciliationTransaction,
|
||||
})
|
||||
}
|
||||
|
||||
func (*Handler) CreateReconcilationTransaction(amount numeric.Numeric, accountUUID uuid.UUID, db *postgres.Queries, c *gin.Context) (*postgres.DisplayTransaction, error) {
|
||||
if amount.IsZero() {
|
||||
return nil, nil //nolint: nilnil
|
||||
}
|
||||
|
||||
createTransaction := postgres.CreateTransactionParams{
|
||||
Date: time.Now(),
|
||||
Memo: "Reconciliation Transaction",
|
||||
Amount: amount,
|
||||
AccountID: accountUUID,
|
||||
Status: "Reconciled",
|
||||
}
|
||||
transactionUUID, err := db.CreateTransaction(c.Request.Context(), createTransaction)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert new transaction: %w", err)
|
||||
}
|
||||
|
||||
transaction, err := db.GetTransaction(c.Request.Context(), transactionUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get created transaction: %w", err)
|
||||
}
|
||||
|
||||
return &transaction, nil
|
||||
}
|
@ -53,18 +53,6 @@ func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
|
||||
token, err := h.verifyLogin(c)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ParamName, token)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
type loginInformation struct {
|
||||
Password string `json:"password"`
|
||||
User string `json:"user"`
|
||||
|
@ -17,12 +17,9 @@ type NewTransactionPayload struct {
|
||||
Payee struct {
|
||||
ID uuid.NullUUID
|
||||
Name string
|
||||
IsAccount bool
|
||||
Type string
|
||||
} `json:"payee"`
|
||||
Category struct {
|
||||
ID uuid.NullUUID
|
||||
Name string
|
||||
} `json:"category"`
|
||||
CategoryID uuid.NullUUID `json:"categoryId"`
|
||||
Memo string `json:"memo"`
|
||||
Amount string `json:"amount"`
|
||||
BudgetID uuid.UUID `json:"budgetId"`
|
||||
@ -44,26 +41,29 @@ func (h *Handler) newTransaction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
transactionID := c.Param("transactionid")
|
||||
if transactionID != "" {
|
||||
h.UpdateTransaction(payload, amount, transactionID, c)
|
||||
return
|
||||
}
|
||||
|
||||
newTransaction := postgres.CreateTransactionParams{
|
||||
Memo: payload.Memo,
|
||||
Date: time.Time(payload.Date),
|
||||
Amount: amount,
|
||||
Status: postgres.TransactionStatus(payload.State),
|
||||
CategoryID: payload.CategoryID,
|
||||
AccountID: payload.AccountID,
|
||||
}
|
||||
|
||||
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 payload.Payee.Type == "account" {
|
||||
groupID, err := h.CreateTransferForOtherAccount(newTransaction, amount, payload, c)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transfer transaction: %w", err))
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
newTransaction.Amount = amount
|
||||
newTransaction.GroupID = groupID
|
||||
} else {
|
||||
payeeID, err := GetPayeeID(c.Request.Context(), payload, h)
|
||||
if err != nil {
|
||||
@ -72,17 +72,70 @@ func (h *Handler) newTransaction(c *gin.Context) {
|
||||
newTransaction.PayeeID = payeeID
|
||||
}
|
||||
|
||||
newTransaction.CategoryID = payload.Category.ID
|
||||
newTransaction.AccountID = payload.AccountID
|
||||
transaction, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
|
||||
transactionUUID, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("get transaction: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, transaction)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateTransaction(payload NewTransactionPayload, amount numeric.Numeric, transactionID string, c *gin.Context) {
|
||||
transactionUUID := uuid.MustParse(transactionID)
|
||||
if amount.IsZero() {
|
||||
err := h.Service.DeleteTransaction(c.Request.Context(), transactionUUID)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("delete transaction: %w", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
editTransaction := postgres.UpdateTransactionParams{
|
||||
Memo: payload.Memo,
|
||||
Date: time.Time(payload.Date),
|
||||
Amount: amount,
|
||||
PayeeID: payload.Payee.ID,
|
||||
CategoryID: payload.CategoryID,
|
||||
ID: transactionUUID,
|
||||
}
|
||||
|
||||
err := h.Service.UpdateTransaction(c.Request.Context(), editTransaction)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("edit transaction: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("get transaction: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, transaction)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateTransferForOtherAccount(newTransaction postgres.CreateTransactionParams, amount numeric.Numeric, payload NewTransactionPayload, c *gin.Context) (uuid.NullUUID, error) {
|
||||
newTransaction.GroupID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
|
||||
newTransaction.Amount = amount.Neg()
|
||||
newTransaction.AccountID = payload.Payee.ID.UUID
|
||||
|
||||
// transfer does not need category. Either it's account is off-budget or no category was supplied.
|
||||
newTransaction.CategoryID = uuid.NullUUID{}
|
||||
|
||||
_, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
|
||||
if err != nil {
|
||||
return uuid.NullUUID{}, fmt.Errorf("create transfer transaction: %w", err)
|
||||
}
|
||||
return newTransaction.GroupID, nil
|
||||
}
|
||||
|
||||
func GetPayeeID(context context.Context, payload NewTransactionPayload, h *Handler) (uuid.NullUUID, error) {
|
||||
payeeID := payload.Payee.ID
|
||||
if payeeID.Valid {
|
||||
|
@ -1,43 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
import { defineComponent, PropType, ref, watch } from "vue"
|
||||
import { ref, watch } from "vue"
|
||||
import { GET } from "../api";
|
||||
import { useBudgetsStore } from "../stores/budget";
|
||||
|
||||
export interface Suggestion {
|
||||
ID: string
|
||||
Name: string
|
||||
}
|
||||
|
||||
interface Data {
|
||||
Selected: Suggestion | undefined
|
||||
SearchQuery: String
|
||||
Suggestions: Suggestion[]
|
||||
Type: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Suggestion | undefined,
|
||||
type: String
|
||||
text: String,
|
||||
id: String | undefined,
|
||||
model: String,
|
||||
type?: string | undefined,
|
||||
}>();
|
||||
|
||||
const Selected = ref<Suggestion | undefined>(props.modelValue || undefined);
|
||||
const SearchQuery = ref(props.modelValue?.Name || "");
|
||||
const SearchQuery = ref(props.text || "");
|
||||
const Suggestions = ref<Array<Suggestion>>([]);
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
const emit = defineEmits(["update:id", "update:text", "update:type"]);
|
||||
watch(SearchQuery, () => {
|
||||
load(SearchQuery.value);
|
||||
});
|
||||
function saveTransaction(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
};
|
||||
function load(text: String) {
|
||||
emit('update:modelValue', { ID: null, Name: text });
|
||||
emit('update:id', null);
|
||||
emit('update:text', text);
|
||||
emit('update:type', undefined);
|
||||
if (text == "") {
|
||||
Suggestions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const budgetStore = useBudgetsStore();
|
||||
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.type + "?s=" + text)
|
||||
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.model + "?s=" + text)
|
||||
.then(x => x.json())
|
||||
.then(x => {
|
||||
let suggestions = x || [];
|
||||
@ -56,13 +51,13 @@ function keypress(e: KeyboardEvent) {
|
||||
const currentIndex = inputElements.indexOf(el);
|
||||
const nextElement = inputElements[currentIndex < inputElements.length - 1 ? currentIndex + 1 : 0];
|
||||
(<HTMLInputElement>nextElement).focus();
|
||||
|
||||
}
|
||||
};
|
||||
function selectElement(element: Suggestion) {
|
||||
Selected.value = element;
|
||||
emit('update:id', element.ID);
|
||||
emit('update:text', element.Name);
|
||||
emit('update:type', element.Type);
|
||||
Suggestions.value = [];
|
||||
emit('update:modelValue', element);
|
||||
};
|
||||
function select(e: MouseEvent) {
|
||||
const target = (<HTMLInputElement>e.target);
|
||||
@ -74,8 +69,9 @@ function select(e: MouseEvent) {
|
||||
selectElement(selected);
|
||||
};
|
||||
function clear() {
|
||||
Selected.value = undefined;
|
||||
emit('update:modelValue', { ID: null, Name: SearchQuery.value });
|
||||
emit('update:id', null);
|
||||
emit('update:text', SearchQuery.value);
|
||||
emit('update:type', undefined);
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -84,10 +80,10 @@ function clear() {
|
||||
<input
|
||||
class="border-b-2 border-black"
|
||||
@keypress="keypress"
|
||||
v-if="Selected == undefined"
|
||||
v-if="id == undefined"
|
||||
v-model="SearchQuery"
|
||||
/>
|
||||
<span @click="clear" v-if="Selected != undefined" class="bg-gray-300">{{ Selected.Name }}</span>
|
||||
<span @click="clear" v-if="id != undefined" class="bg-gray-300">{{ text }}</span>
|
||||
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 w-64 p-2">
|
||||
<span
|
||||
v-for="suggestion in Suggestions"
|
||||
|
33
web/src/components/DateInput.vue
Normal file
33
web/src/components/DateInput.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps(["modelValue"]);
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
function dateToYYYYMMDD(d: Date) : string {
|
||||
// alternative implementations in https://stackoverflow.com/q/23593052/1850609
|
||||
//return new Date(d.getTime() - (d.getTimezoneOffset() * 60 * 1000)).toISOString().split('T')[0];
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function updateValue(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit('update:modelValue', target.valueAsDate);
|
||||
}
|
||||
function selectAll(event: FocusEvent) {
|
||||
// Workaround for Safari bug
|
||||
// http://stackoverflow.com/questions/1269722/selecting-text-on-focus-using-jquery-not-working-in-safari-and-chrome
|
||||
setTimeout(function () {
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.select()
|
||||
}, 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
type="date"
|
||||
ref="input"
|
||||
v-bind:value="dateToYYYYMMDD(modelValue)"
|
||||
@input="updateValue"
|
||||
@focus="selectAll"
|
||||
/>
|
||||
</template>
|
63
web/src/components/TransactionEditRow.vue
Normal file
63
web/src/components/TransactionEditRow.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import Autocomplete from './Autocomplete.vue'
|
||||
import { useAccountStore } from '../stores/budget-account'
|
||||
import DateInput from "./DateInput.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
transactionid: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(["save"]);
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const TX = accountStore.Transactions.get(props.transactionid)!;
|
||||
const payeeType = ref<string|undefined>(undefined);
|
||||
|
||||
const payload = computed(() => JSON.stringify({
|
||||
date: TX.Date.toISOString().split("T")[0],
|
||||
payee: {
|
||||
Name: TX.Payee,
|
||||
ID: TX.PayeeID,
|
||||
Type: payeeType.value,
|
||||
},
|
||||
categoryId: TX.CategoryID,
|
||||
memo: TX.Memo,
|
||||
amount: TX.Amount.toString(),
|
||||
state: "Uncleared"
|
||||
}));
|
||||
|
||||
function saveTransaction(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
accountStore.editTransaction(TX.ID, payload.value);
|
||||
emit('save');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr>
|
||||
<td style="width: 90px;" class="text-sm">
|
||||
<DateInput class="border-b-2 border-black" v-model="TX.Date" />
|
||||
</td>
|
||||
<td style="max-width: 150px;">
|
||||
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" />
|
||||
</td>
|
||||
<td style="max-width: 200px;">
|
||||
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" />
|
||||
</td>
|
||||
<td>
|
||||
<input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" />
|
||||
</td>
|
||||
<td style="width: 80px;" class="text-right">
|
||||
<input
|
||||
class="text-right block w-full border-b-2 border-black"
|
||||
type="currency"
|
||||
v-model="TX.Amount"
|
||||
/>
|
||||
</td>
|
||||
<td style="width: 20px;">
|
||||
<input type="submit" @click="saveTransaction" value="Save" />
|
||||
</td>
|
||||
<td style="width: 20px;"></td>
|
||||
</tr>
|
||||
</template>
|
@ -1,27 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
|
||||
import { useAccountStore } from '../stores/budget-account'
|
||||
import { Transaction, useAccountStore } from '../stores/budget-account'
|
||||
import DateInput from "./DateInput.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
budgetid: string
|
||||
accountid: string
|
||||
}>()
|
||||
|
||||
const TransactionDate = ref(new Date().toISOString().substring(0, 10));
|
||||
const Payee = ref<Suggestion | undefined>(undefined);
|
||||
const Category = ref<Suggestion | undefined>(undefined);
|
||||
const Memo = ref("");
|
||||
const Amount = ref("0");
|
||||
const TX = ref<Transaction>({
|
||||
Date: new Date(),
|
||||
Memo: "",
|
||||
Amount: 0,
|
||||
Payee: "",
|
||||
PayeeID: undefined,
|
||||
Category: "",
|
||||
CategoryID: undefined,
|
||||
CategoryGroup: "",
|
||||
GroupID: "",
|
||||
ID: "",
|
||||
Status: "Uncleared",
|
||||
TransferAccount: "",
|
||||
});
|
||||
|
||||
const payeeType = ref<string|undefined>(undefined);
|
||||
|
||||
const payload = computed(() => JSON.stringify({
|
||||
budgetId: props.budgetid,
|
||||
accountId: props.accountid,
|
||||
date: TransactionDate.value,
|
||||
payee: Payee.value,
|
||||
category: Category.value,
|
||||
memo: Memo.value,
|
||||
amount: Amount.value,
|
||||
date: TX.value.Date.toISOString().split("T")[0],
|
||||
payee: {
|
||||
Name: TX.value.Payee,
|
||||
ID: TX.value.PayeeID,
|
||||
Type: payeeType.value,
|
||||
},
|
||||
categoryId: TX.value.CategoryID,
|
||||
memo: TX.value.Memo,
|
||||
amount: TX.value.Amount.toString(),
|
||||
state: "Uncleared"
|
||||
}));
|
||||
|
||||
@ -35,22 +51,22 @@ function saveTransaction(e: MouseEvent) {
|
||||
<template>
|
||||
<tr>
|
||||
<td style="width: 90px;" class="text-sm">
|
||||
<input class="border-b-2 border-black" type="date" v-model="TransactionDate" />
|
||||
<DateInput class="border-b-2 border-black" v-model="TX.Date" />
|
||||
</td>
|
||||
<td style="max-width: 150px;">
|
||||
<Autocomplete v-model="Payee" type="payees" />
|
||||
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" />
|
||||
</td>
|
||||
<td style="max-width: 200px;">
|
||||
<Autocomplete v-model="Category" type="categories" />
|
||||
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" />
|
||||
</td>
|
||||
<td>
|
||||
<input class="block w-full border-b-2 border-black" type="text" v-model="Memo" />
|
||||
<input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" />
|
||||
</td>
|
||||
<td style="width: 80px;" class="text-right">
|
||||
<input
|
||||
class="text-right block w-full border-b-2 border-black"
|
||||
type="currency"
|
||||
v-model="Amount"
|
||||
v-model="TX.Amount"
|
||||
/>
|
||||
</td>
|
||||
<td style="width: 20px;">
|
||||
|
@ -1,39 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useBudgetsStore } from "../stores/budget";
|
||||
import { Transaction } from "../stores/budget-account";
|
||||
import { Transaction, useAccountStore } from "../stores/budget-account";
|
||||
import Currency from "./Currency.vue";
|
||||
import TransactionEditRow from "./TransactionEditRow.vue";
|
||||
import { formatDate } from "../date";
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: Transaction,
|
||||
index: number,
|
||||
}>();
|
||||
|
||||
const CurrentBudgetID = computed(()=> useBudgetsStore().CurrentBudgetID);
|
||||
const edit = ref(false);
|
||||
|
||||
const CurrentBudgetID = computed(() => useBudgetsStore().CurrentBudgetID);
|
||||
const Reconciling = computed(() => useAccountStore().Reconciling);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="{{transaction.Date.After now ? 'future' : ''}}"
|
||||
:class="[index % 6 < 3 ? 'bg-gray-300' : 'bg-gray-100']">
|
||||
<tr
|
||||
v-if="!edit"
|
||||
class="{{new Date(transaction.Date) > new Date() ? 'future' : ''}}"
|
||||
:class="[index % 6 < 3 ? 'bg-gray-300' : 'bg-gray-100']"
|
||||
>
|
||||
<!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">-->
|
||||
<td style="width: 90px;">{{ transaction.Date.substring(0, 10) }}</td>
|
||||
<td style="max-width: 150px;">{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td>
|
||||
<td style="max-width: 200px;">
|
||||
{{ transaction.CategoryGroup ? transaction.CategoryGroup + " : " + transaction.Category : "" }}
|
||||
</td>
|
||||
<td>{{ formatDate(transaction.Date) }}</td>
|
||||
<td>{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td>
|
||||
<td>{{ transaction.CategoryGroup ? transaction.CategoryGroup + " : " + transaction.Category : "" }}</td>
|
||||
<td>
|
||||
<a :href="'/budget/' + CurrentBudgetID + '/transaction/' + transaction.ID">
|
||||
{{ transaction.Memo }}
|
||||
</a>
|
||||
<a
|
||||
:href="'/budget/' + CurrentBudgetID + '/transaction/' + transaction.ID"
|
||||
>{{ transaction.Memo }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<Currency class="block" :value="transaction.Amount" />
|
||||
</td>
|
||||
<td style="width: 20px;">
|
||||
{{ transaction.Status == "Reconciled" ? "✔" : (transaction.Status == "Uncleared" ? "" : "*") }}
|
||||
<td>{{ transaction.Status == "Reconciled" ? "✔" : (transaction.Status == "Uncleared" ? "" : "*") }}</td>
|
||||
<td class="text-right">
|
||||
{{ transaction.GroupID ? "☀" : "" }}
|
||||
<a @click="edit = true;">✎</a>
|
||||
</td>
|
||||
<td v-if="Reconciling && transaction.Status != 'Reconciled'">
|
||||
<input type="checkbox" v-model="transaction.Reconciled" />
|
||||
</td>
|
||||
<td style="width: 20px;">{{ transaction.GroupID ? "☀" : "" }}</td>
|
||||
</tr>
|
||||
<TransactionEditRow v-if="edit" :transactionid="transaction.ID" @save="edit = false" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
7
web/src/date.ts
Normal file
7
web/src/date.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, { // you can use undefined as first argument
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
44
web/src/dialogs/EditAccount.vue
Normal file
44
web/src/dialogs/EditAccount.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import { useAccountStore } from '../stores/budget-account';
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const CurrentAccount = computed(() => accountStore.CurrentAccount);
|
||||
|
||||
const accountName = ref("");
|
||||
const accountOnBudget = ref(true);
|
||||
|
||||
function editAccount(e : any) {
|
||||
accountStore.EditAccount(CurrentAccount.value?.ID ?? "", accountName.value, accountOnBudget.value);
|
||||
}
|
||||
|
||||
function openEditAccount(e : any) {
|
||||
accountName.value = CurrentAccount.value?.Name ?? "";
|
||||
accountOnBudget.value = CurrentAccount.value?.OnBudget ?? true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal button-text="Edit Account" @open="openEditAccount" @submit="editAccount">
|
||||
<template v-slot:placeholder>✎</template>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<input
|
||||
class="border-2"
|
||||
type="text"
|
||||
v-model="accountName"
|
||||
placeholder="Account name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<input
|
||||
class="border-2"
|
||||
type="checkbox"
|
||||
v-model="accountOnBudget"
|
||||
required
|
||||
/>
|
||||
<label>On Budget</label>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
@ -4,58 +4,78 @@ import Currency from "../components/Currency.vue";
|
||||
import TransactionRow from "../components/TransactionRow.vue";
|
||||
import TransactionInputRow from "../components/TransactionInputRow.vue";
|
||||
import { useAccountStore } from "../stores/budget-account";
|
||||
import Modal from "../components/Modal.vue";
|
||||
import EditAccount from "../dialogs/EditAccount.vue";
|
||||
import Button from "../components/Button.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
budgetid: string
|
||||
accountid: string
|
||||
}>()
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const CurrentAccount = computed(() => accountStore.CurrentAccount);
|
||||
const TransactionsList = computed(() => accountStore.TransactionsList);
|
||||
const accounts = useAccountStore();
|
||||
const TargetReconcilingBalance = ref(0);
|
||||
|
||||
const accountName = ref("");
|
||||
const accountOnBudget = ref(true);
|
||||
|
||||
function editAccount(e : any) {
|
||||
accountStore.EditAccount(CurrentAccount.value?.ID ?? "", accountName.value, accountOnBudget.value);
|
||||
function setReconciled(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
accounts.SetReconciledForAllTransactions(target.checked);
|
||||
}
|
||||
|
||||
function openEditAccount(e : any) {
|
||||
accountName.value = CurrentAccount.value?.Name ?? "";
|
||||
accountOnBudget.value = CurrentAccount.value?.OnBudget ?? true;
|
||||
function cancelReconcilation() {
|
||||
accounts.SetReconciledForAllTransactions(false);
|
||||
accounts.Reconciling = false;
|
||||
}
|
||||
|
||||
function submitReconcilation() {
|
||||
accounts.SubmitReconcilation(0);
|
||||
accounts.Reconciling = false;
|
||||
}
|
||||
|
||||
function createReconcilationTransaction() {
|
||||
const diff = TargetReconcilingBalance.value - accounts.ReconcilingBalance ;
|
||||
accounts.SubmitReconcilation(diff);
|
||||
accounts.Reconciling = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 class="inline">{{ CurrentAccount?.Name }}</h1>
|
||||
<Modal button-text="Edit Account" @open="openEditAccount" @submit="editAccount">
|
||||
<template v-slot:placeholder>✎</template>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<input
|
||||
class="border-2"
|
||||
type="text"
|
||||
v-model="accountName"
|
||||
placeholder="Account name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<input
|
||||
class="border-2"
|
||||
type="checkbox"
|
||||
v-model="accountOnBudget"
|
||||
required
|
||||
/>
|
||||
<label>On Budget</label>
|
||||
</div>
|
||||
</Modal>
|
||||
<h1 class="inline">{{ accounts.CurrentAccount?.Name }}</h1>
|
||||
<EditAccount />
|
||||
<br />
|
||||
|
||||
<p>
|
||||
<span>
|
||||
Current Balance:
|
||||
<Currency :value="CurrentAccount?.Balance" />
|
||||
</p>
|
||||
<Currency :value="accounts.CurrentAccount?.WorkingBalance" />
|
||||
</span>
|
||||
|
||||
<span>
|
||||
Cleared Balance:
|
||||
<Currency :value="accounts.CurrentAccount?.ClearedBalance" />
|
||||
</span>
|
||||
|
||||
<span v-if="accounts.Reconciling" class="border-2 block bg-gray-200 rounded-lg p-2">
|
||||
Is <Currency :value="accounts.ReconcilingBalance" /> your current balance?
|
||||
<Button
|
||||
class="bg-blue-500 mx-3"
|
||||
@click="submitReconcilation">Yes!</Button>
|
||||
<br />
|
||||
|
||||
No, it's: <input class="text-right" type="number" v-model="TargetReconcilingBalance" />
|
||||
Difference: <Currency :value="accounts.ReconcilingBalance - TargetReconcilingBalance" />
|
||||
<Button
|
||||
class="bg-orange-500 mx-3"
|
||||
v-if="Math.abs(accounts.ReconcilingBalance - TargetReconcilingBalance) > 0.01"
|
||||
@click="createReconcilationTransaction"
|
||||
>Create reconciling Transaction</Button>
|
||||
<Button
|
||||
class="bg-red-500 mx-3"
|
||||
@click="cancelReconcilation"
|
||||
>Cancel</Button>
|
||||
</span>
|
||||
<span v-if="!accounts.Reconciling">
|
||||
Reconciled Balance:
|
||||
<Currency :value="accounts.CurrentAccount?.ReconciledBalance" />
|
||||
<Button class="bg-blue-500" @click="accounts.Reconciling = true" v-if="!accounts.Reconciling">Reconcile</Button>
|
||||
</span>
|
||||
<table>
|
||||
<tr class="font-bold">
|
||||
<td style="width: 90px;">Date</td>
|
||||
@ -64,11 +84,14 @@ function openEditAccount(e : any) {
|
||||
<td>Memo</td>
|
||||
<td class="text-right">Amount</td>
|
||||
<td style="width: 20px;"></td>
|
||||
<td style="width: 20px;"></td>
|
||||
<td style="width: 40px;"></td>
|
||||
<td style="width: 20px;" v-if="accounts.Reconciling">
|
||||
<input type="checkbox" @input="setReconciled" />
|
||||
</td>
|
||||
</tr>
|
||||
<TransactionInputRow :budgetid="budgetid" :accountid="accountid" />
|
||||
<TransactionRow
|
||||
v-for="(transaction, index) in TransactionsList"
|
||||
v-for="(transaction, index) in accounts.TransactionsList" :key="transaction.ID"
|
||||
:transaction="transaction"
|
||||
:index="index"
|
||||
/>
|
||||
|
@ -36,7 +36,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
|
||||
</div>
|
||||
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between">
|
||||
<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.ClearedBalance" />
|
||||
</div>
|
||||
</li>
|
||||
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
|
||||
@ -46,7 +46,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
|
||||
</div>
|
||||
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between">
|
||||
<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.ClearedBalance" />
|
||||
</div>
|
||||
</li>
|
||||
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
|
||||
|
@ -4,32 +4,39 @@ import { useBudgetsStore } from "./budget";
|
||||
import { useSessionStore } from "./session";
|
||||
|
||||
interface State {
|
||||
Accounts: Map<string, Account>,
|
||||
CurrentAccountID: string | null,
|
||||
Categories: Map<string, Category>,
|
||||
Months: Map<number, Map<number, Map<string, Category>>>,
|
||||
Transactions: any[],
|
||||
Accounts: Map<string, Account>
|
||||
CurrentAccountID: string | null
|
||||
Categories: Map<string, Category>
|
||||
Months: Map<number, Map<number, Map<string, Category>>>
|
||||
Transactions: Map<string, Transaction>
|
||||
Assignments: []
|
||||
Reconciling: boolean
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
ID: string,
|
||||
Date: string,
|
||||
TransferAccount: string,
|
||||
CategoryGroup: string,
|
||||
Category: string,
|
||||
Memo: string,
|
||||
Status: string,
|
||||
GroupID: string,
|
||||
Payee: string,
|
||||
Amount: number,
|
||||
ID: string
|
||||
Date: Date
|
||||
TransferAccount: string
|
||||
CategoryGroup: string
|
||||
Category: string
|
||||
CategoryID: string | undefined
|
||||
Memo: string
|
||||
Status: string
|
||||
GroupID: string
|
||||
Payee: string
|
||||
PayeeID: string | undefined
|
||||
Amount: number
|
||||
Reconciled: boolean
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
ID: string
|
||||
Name: string
|
||||
OnBudget: boolean
|
||||
Balance: number
|
||||
ClearedBalance: number
|
||||
WorkingBalance: number
|
||||
ReconciledBalance: number
|
||||
Transactions: string[]
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@ -48,8 +55,9 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
CurrentAccountID: null,
|
||||
Months: new Map<number, Map<number, Map<string, Category>>>(),
|
||||
Categories: new Map<string, Category>(),
|
||||
Transactions: [],
|
||||
Assignments: []
|
||||
Transactions: new Map<string, Transaction>(),
|
||||
Assignments: [],
|
||||
Reconciling: false,
|
||||
}),
|
||||
getters: {
|
||||
AccountsList(state) {
|
||||
@ -66,7 +74,7 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
const categoryGroups = [];
|
||||
let prev = undefined;
|
||||
for (const category of categories) {
|
||||
if(category.Group != prev)
|
||||
if (category.Group != prev)
|
||||
categoryGroups.push({
|
||||
Name: category.Group,
|
||||
Expand: category.Group != "Hidden Categories",
|
||||
@ -77,7 +85,7 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
}
|
||||
},
|
||||
CategoriesForMonthAndGroup(state) {
|
||||
return (year: number, month: number, group : string) => {
|
||||
return (year: number, month: number, group: string) => {
|
||||
const categories = this.AllCategoriesForMonth(year, month);
|
||||
return categories.filter(x => x.Group == group);
|
||||
}
|
||||
@ -88,21 +96,31 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
|
||||
return state.Accounts.get(state.CurrentAccountID);
|
||||
},
|
||||
ReconcilingBalance(state): number {
|
||||
let reconciledBalance = this.CurrentAccount!.ReconciledBalance;
|
||||
for (const transaction of this.TransactionsList) {
|
||||
if (transaction.Reconciled)
|
||||
reconciledBalance += transaction.Amount;
|
||||
}
|
||||
return reconciledBalance;
|
||||
},
|
||||
OnBudgetAccounts(state) {
|
||||
return [...state.Accounts.values()].filter(x => x.OnBudget);
|
||||
},
|
||||
OnBudgetAccountsBalance(state): number {
|
||||
return this.OnBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
|
||||
return this.OnBudgetAccounts.reduce((prev, curr) => prev + Number(curr.ClearedBalance), 0);
|
||||
},
|
||||
OffBudgetAccounts(state) {
|
||||
return [...state.Accounts.values()].filter(x => !x.OnBudget);
|
||||
},
|
||||
OffBudgetAccountsBalance(state): number {
|
||||
return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
|
||||
return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.ClearedBalance), 0);
|
||||
},
|
||||
TransactionsList(state): Transaction[] {
|
||||
return this.CurrentAccount!.Transactions.map(x => {
|
||||
return this.Transactions.get(x)!
|
||||
});
|
||||
},
|
||||
TransactionsList(state) {
|
||||
return (state.Transactions || []);
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async SetCurrentAccount(budgetid: string, accountid: string) {
|
||||
@ -110,26 +128,35 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
return
|
||||
|
||||
this.CurrentAccountID = accountid;
|
||||
if (this.CurrentAccount == undefined)
|
||||
const account = this.CurrentAccount;
|
||||
if (account == undefined)
|
||||
return
|
||||
|
||||
useSessionStore().setTitle(this.CurrentAccount.Name);
|
||||
await this.FetchAccount(accountid);
|
||||
useSessionStore().setTitle(account.Name);
|
||||
await this.FetchAccount(account);
|
||||
},
|
||||
async FetchAccount(accountid: string) {
|
||||
const result = await GET("/account/" + accountid + "/transactions");
|
||||
AddTransaction(account: Account, transaction: any) {
|
||||
transaction.Date = new Date(transaction.Date);
|
||||
this.Transactions.set(transaction.ID, transaction);
|
||||
},
|
||||
async FetchAccount(account: Account) {
|
||||
const result = await GET("/account/" + account.ID + "/transactions");
|
||||
const response = await result.json();
|
||||
this.Transactions = response.Transactions;
|
||||
account.Transactions = [];
|
||||
for (const transaction of response.Transactions) {
|
||||
this.AddTransaction(account, transaction);
|
||||
account.Transactions.push(transaction.ID);
|
||||
}
|
||||
},
|
||||
async FetchMonthBudget(budgetid: string, year: number, month: number) {
|
||||
const result = await GET("/budget/" + budgetid + "/" + year + "/" + month);
|
||||
const response = await result.json();
|
||||
if(response.Categories == undefined || response.Categories.length <= 0)
|
||||
if (response.Categories == undefined || response.Categories.length <= 0)
|
||||
return;
|
||||
this.addCategoriesForMonth(year, month, response.Categories);
|
||||
},
|
||||
async EditAccount(accountid : string, name : string, onBudget : boolean) {
|
||||
const result = await POST("/account/" + accountid, JSON.stringify({name: name, onBudget: onBudget}));
|
||||
async EditAccount(accountid: string, name: string, onBudget: boolean) {
|
||||
const result = await POST("/account/" + accountid, JSON.stringify({ name: name, onBudget: onBudget }));
|
||||
const response = await result.json();
|
||||
useBudgetsStore().MergeBudgetingData(response);
|
||||
},
|
||||
@ -145,13 +172,47 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
state.Months.set(year, yearMap);
|
||||
});
|
||||
},
|
||||
SetReconciledForAllTransactions(value: boolean) {
|
||||
for (const transaction of this.TransactionsList) {
|
||||
if (transaction.Status == "Reconciled")
|
||||
continue;
|
||||
|
||||
transaction.Reconciled = value;
|
||||
}
|
||||
},
|
||||
async SubmitReconcilation(reconciliationTransactionAmount: number) {
|
||||
const account = this.CurrentAccount!;
|
||||
const reconciledTransactions = this.TransactionsList.filter(x => x.Reconciled);
|
||||
for (const transaction of reconciledTransactions) {
|
||||
account.ReconciledBalance += transaction.Amount;
|
||||
transaction.Status = "Reconciled";
|
||||
transaction.Reconciled = false;
|
||||
}
|
||||
const result = await POST("/account/" + this.CurrentAccountID + "/reconcile", JSON.stringify({
|
||||
transactionIDs: reconciledTransactions.map(x => x.ID),
|
||||
reconciliationTransactionAmount: reconciliationTransactionAmount.toString(),
|
||||
}));
|
||||
const response = await result.json();
|
||||
const recTrans = response.ReconciliationTransaction;
|
||||
if (recTrans) {
|
||||
this.AddTransaction(account, recTrans);
|
||||
account.Transactions.unshift(recTrans.ID);
|
||||
}
|
||||
console.log("Reconcile: " + response.message);
|
||||
},
|
||||
logout() {
|
||||
this.$reset()
|
||||
},
|
||||
async saveTransaction(payload: string) {
|
||||
const result = await POST("/transaction/new", payload);
|
||||
const response = await result.json();
|
||||
this.Transactions.unshift(response);
|
||||
this.AddTransaction(this.CurrentAccount!, response);
|
||||
this.CurrentAccount?.Transactions.unshift(response.ID);
|
||||
},
|
||||
async editTransaction(transactionid: string, payload: string) {
|
||||
const result = await POST("/transaction/" + transactionid, payload);
|
||||
const response = await result.json();
|
||||
this.AddTransaction(this.CurrentAccount!, response);
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user