Compare commits
72 Commits
v0.1.0
...
2843d8a2f1
Author | SHA1 | Date | |
---|---|---|---|
2843d8a2f1 | |||
843dcd2536 | |||
a147830e12 | |||
b0776023b4 | |||
0b95cdc1d9 | |||
2ec9c923df | |||
beff7afcf7 | |||
951e827d20 | |||
2f3e4bc748 | |||
d71eb17092 | |||
53dd31fa35 | |||
1a4267186a | |||
5018e5b973 | |||
ed9e75d57a | |||
ed361324dd | |||
6bac09a38e | |||
ab43387f06 | |||
c112d95a41 | |||
6fdc0e3b1d | |||
f08784ffa7 | |||
8188184ac9 | |||
81b3bf334a | |||
d0ad0dcb3a | |||
1ab1fa74e0 | |||
33c54c9f4c | |||
1ed9344586 | |||
a8bd03a805 | |||
9e01be699a | |||
84ddb36d62 | |||
8b6a8c3697 | |||
208ffce968 | |||
bfba5f4028 | |||
1f2d81f173 | |||
c3a93377d9 | |||
40a299141d | |||
935499e3a8 | |||
915964fa4e | |||
e9adc763b2 | |||
d5ebf5a5cf | |||
466775817f | |||
e2413290b4 | |||
18cd29cca2 | |||
caf0126b86 | |||
6da1b26a2f | |||
13993b6b5a | |||
625e0635fd | |||
1826274ccc | |||
defbbd1884 | |||
8116238d48 | |||
e0eeaadc60 | |||
4cd81592e4 | |||
5d9693838f | |||
3bec0857d5 | |||
5e18d51b5d | |||
11179a1593 | |||
7cb7527704 | |||
c3a022b595 | |||
a0ebdd01aa | |||
edd1319222 | |||
a19d3d6932 | |||
f4ddf12214 | |||
04fd687324 | |||
cbda69e827 | |||
e3f3dc6748 | |||
915379f5cb | |||
284685fb52 | |||
5f4c5d9d51 | |||
8c9c78a789 | |||
64822912d9 | |||
1d4bc158a8 | |||
fbd283cd1c | |||
0ee3f269b5 |
27
.drone.yml
Normal file
27
.drone.yml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: budgeteer
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Taskfile.dev
|
||||||
|
image: hub.javil.eu/budgeteer:dev
|
||||||
|
commands:
|
||||||
|
- task build
|
||||||
|
|
||||||
|
- name: docker
|
||||||
|
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
|
||||||
|
tags:
|
||||||
|
- latest
|
||||||
|
|
||||||
|
image_pull_secrets:
|
||||||
|
- hub.javil.eu
|
11
.vscode/tasks.json
vendored
11
.vscode/tasks.json
vendored
@ -4,14 +4,21 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "earthly +run",
|
"label": "task watch +run",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "earthly +run",
|
"command": "task -w run",
|
||||||
"problemMatcher": [],
|
"problemMatcher": [],
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "build",
|
"kind": "build",
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "earthly +run",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "earthly +run",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": "build"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
3
Dockerfile.dev
Normal file
3
Dockerfile.dev
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
FROM golang:1.17
|
||||||
|
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
|
||||||
|
RUN go install github.com/go-task/task/v3/cmd/task@latest
|
61
Taskfile.yml
Normal file
61
Taskfile.yml
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
default:
|
||||||
|
cmds:
|
||||||
|
- task: build
|
||||||
|
|
||||||
|
sqlc:
|
||||||
|
desc: sqlc code generation
|
||||||
|
sources:
|
||||||
|
- ./sqlc.yaml
|
||||||
|
- ./postgres/schema/*
|
||||||
|
- ./postgres/queries/*
|
||||||
|
generates:
|
||||||
|
- ./postgres/*.sql.go
|
||||||
|
cmds:
|
||||||
|
- sqlc generate
|
||||||
|
|
||||||
|
gomod:
|
||||||
|
desc: Go modules
|
||||||
|
sources:
|
||||||
|
- ./go.mod
|
||||||
|
- ./go.sum
|
||||||
|
method: checksum
|
||||||
|
cmds:
|
||||||
|
- go mod download
|
||||||
|
|
||||||
|
build:
|
||||||
|
desc: Build budgeteer
|
||||||
|
deps: [gomod, sqlc]
|
||||||
|
sources:
|
||||||
|
- ./go.mod
|
||||||
|
- ./go.sum
|
||||||
|
- ./cmd/budgeteer/*.go
|
||||||
|
- ./*.go
|
||||||
|
- ./config/*.go
|
||||||
|
- ./http/*.go
|
||||||
|
- ./jwt/*.go
|
||||||
|
- ./postgres/*.go
|
||||||
|
- ./web/**/*
|
||||||
|
- ./postgres/schema/*
|
||||||
|
generates:
|
||||||
|
- build/budgeteer{{exeExt}}
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: '0'
|
||||||
|
cmds:
|
||||||
|
- go build -o ./build/budgeteer{{exeExt}} ./cmd/budgeteer
|
||||||
|
|
||||||
|
docker:
|
||||||
|
desc: Build budgeeter:latest
|
||||||
|
deps: [build]
|
||||||
|
sources:
|
||||||
|
- ./build/budgeteer
|
||||||
|
cmds:
|
||||||
|
- docker build -t budgeteer:latest -t hub.javil.eu/budgeteer:latest ./build
|
||||||
|
|
||||||
|
run:
|
||||||
|
desc: Start docker-compose
|
||||||
|
deps: [docker]
|
||||||
|
cmds:
|
||||||
|
- docker-compose up -d
|
3
build/Dockerfile
Normal file
3
build/Dockerfile
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
FROM scratch
|
||||||
|
COPY ./budgeteer /app/budgeteer
|
||||||
|
ENTRYPOINT ["/app/budgeteer"]
|
@ -13,25 +13,20 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.LoadConfig()
|
cfg, err := config.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Could not load Config: %v", err)
|
log.Fatalf("Could not load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bv := &bcrypt.Verifier{}
|
bv := &bcrypt.Verifier{}
|
||||||
|
|
||||||
q, db, err := postgres.Connect(cfg.DatabaseHost, cfg.DatabaseUser, cfg.DatabasePassword, cfg.DatabaseName)
|
q, err := postgres.Connect(cfg.DatabaseHost, cfg.DatabaseUser, cfg.DatabasePassword, cfg.DatabaseName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed connecting to DB: %v", err)
|
log.Fatalf("Failed connecting to DB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
us, err := postgres.NewRepository(q, db)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed building Repository: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tv := &jwt.TokenVerifier{}
|
tv := &jwt.TokenVerifier{}
|
||||||
|
|
||||||
h := &http.Handler{
|
h := &http.Handler{
|
||||||
Service: us,
|
Service: q,
|
||||||
TokenVerifier: tv,
|
TokenVerifier: tv,
|
||||||
CredentialsVerifier: bv,
|
CredentialsVerifier: bv,
|
||||||
}
|
}
|
||||||
|
3
go.mod
3
go.mod
@ -5,7 +5,6 @@ go 1.17
|
|||||||
require (
|
require (
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||||
github.com/gin-gonic/gin v1.7.4
|
github.com/gin-gonic/gin v1.7.4
|
||||||
github.com/gofrs/uuid v4.0.0+incompatible
|
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
github.com/jackc/pgx/v4 v4.13.0
|
github.com/jackc/pgx/v4 v4.13.0
|
||||||
github.com/pressly/goose/v3 v3.3.1
|
github.com/pressly/goose/v3 v3.3.1
|
||||||
@ -24,7 +23,7 @@ require (
|
|||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgproto3/v2 v2.1.1 // indirect
|
github.com/jackc/pgproto3/v2 v2.1.1 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
||||||
github.com/jackc/pgtype v1.8.1 // indirect
|
github.com/jackc/pgtype v1.8.1 // direct
|
||||||
github.com/json-iterator/go v1.1.9 // indirect
|
github.com/json-iterator/go v1.1.9 // indirect
|
||||||
github.com/leodido/go-urn v1.2.0 // indirect
|
github.com/leodido/go-urn v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.12 // indirect
|
github.com/mattn/go-isatty v0.0.12 // indirect
|
||||||
|
54
http/account.go
Normal file
54
http/account.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountData struct {
|
||||||
|
AlwaysNeededData
|
||||||
|
Account *postgres.Account
|
||||||
|
Categories []postgres.GetCategoriesRow
|
||||||
|
Transactions []postgres.GetTransactionsForAccountRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) account(c *gin.Context) {
|
||||||
|
data := c.MustGet("data").(AlwaysNeededData)
|
||||||
|
|
||||||
|
accountID := c.Param("accountid")
|
||||||
|
accountUUID, err := uuid.Parse(accountID)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.Service.GetAccount(c.Request.Context(), accountUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := h.Service.GetCategories(c.Request.Context(), data.Budget.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions, err := h.Service.GetTransactionsForAccount(c.Request.Context(), accountUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d := AccountData{
|
||||||
|
data,
|
||||||
|
&account,
|
||||||
|
categories,
|
||||||
|
transactions,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "account.html", d)
|
||||||
|
}
|
@ -3,9 +3,7 @@ package http
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccountsData struct {
|
type AccountsData struct {
|
||||||
@ -19,39 +17,3 @@ func (h *Handler) accounts(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(http.StatusOK, "accounts.html", d)
|
c.HTML(http.StatusOK, "accounts.html", d)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountData struct {
|
|
||||||
AlwaysNeededData
|
|
||||||
Account *postgres.Account
|
|
||||||
Transactions []postgres.GetTransactionsForAccountRow
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) account(c *gin.Context) {
|
|
||||||
|
|
||||||
accountID := c.Param("accountid")
|
|
||||||
accountUUID, err := uuid.Parse(accountID)
|
|
||||||
if err != nil {
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
account, err := h.Service.DB.GetAccount(c.Request.Context(), accountUUID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusNotFound, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactions, err := h.Service.DB.GetTransactionsForAccount(c.Request.Context(), accountUUID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusNotFound, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
d := AccountData{
|
|
||||||
c.MustGet("data").(AlwaysNeededData),
|
|
||||||
&account,
|
|
||||||
transactions,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "account.html", d)
|
|
||||||
}
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -19,13 +21,97 @@ func (h *Handler) admin(c *gin.Context) {
|
|||||||
func (h *Handler) clearDatabase(c *gin.Context) {
|
func (h *Handler) clearDatabase(c *gin.Context) {
|
||||||
d := AdminData{}
|
d := AdminData{}
|
||||||
|
|
||||||
if err := goose.Down(h.Service.LegacyDB, "schema"); err != nil {
|
if err := goose.Reset(h.Service.DB, "schema"); err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := goose.Up(h.Service.LegacyDB, "schema"); err != nil {
|
if err := goose.Up(h.Service.DB, "schema"); err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "admin.html", d)
|
c.HTML(http.StatusOK, "admin.html", d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsData struct {
|
||||||
|
AlwaysNeededData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) settings(c *gin.Context) {
|
||||||
|
d := SettingsData{
|
||||||
|
c.MustGet("data").(AlwaysNeededData),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "settings.html", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) clearBudget(c *gin.Context) {
|
||||||
|
budgetID := c.Param("budgetid")
|
||||||
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.Service.DeleteAllAssignments(c.Request.Context(), budgetUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Deleted %d assignments\n", rows)
|
||||||
|
|
||||||
|
rows, err = h.Service.DeleteAllTransactions(c.Request.Context(), budgetUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Deleted %d transactions\n", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) cleanNegativeBudget(c *gin.Context) {
|
||||||
|
/*budgetID := c.Param("budgetid")
|
||||||
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
||||||
|
return
|
||||||
|
}*/
|
||||||
|
|
||||||
|
/*min_date, err := h.Service.GetFirstActivity(c.Request.Context(), budgetUUID)
|
||||||
|
date := getFirstOfMonthTime(min_date)
|
||||||
|
for {
|
||||||
|
nextDate := date.AddDate(0, 1, 0)
|
||||||
|
params := postgres.GetCategoriesWithBalanceParams{
|
||||||
|
BudgetID: budgetUUID,
|
||||||
|
ToDate: nextDate,
|
||||||
|
FromDate: date,
|
||||||
|
}
|
||||||
|
categories, err := h.Service.GetCategoriesWithBalance(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, category := range categories {
|
||||||
|
available := category.Available.GetFloat64()
|
||||||
|
if available >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var negativeAvailable postgres.Numeric
|
||||||
|
negativeAvailable.Set(-available)
|
||||||
|
createAssignment := postgres.CreateAssignmentParams{
|
||||||
|
Date: nextDate.AddDate(0, 0, -1),
|
||||||
|
Amount: negativeAvailable,
|
||||||
|
CategoryID: category.ID,
|
||||||
|
}
|
||||||
|
h.Service.CreateAssignment(c.Request.Context(), createAssignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nextDate.Before(time.Now()) {
|
||||||
|
date = nextDate
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
@ -10,8 +9,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AlwaysNeededData struct {
|
type AlwaysNeededData struct {
|
||||||
Budget postgres.Budget
|
Budget postgres.Budget
|
||||||
Accounts []postgres.GetAccountsWithBalanceRow
|
Accounts []postgres.GetAccountsWithBalanceRow
|
||||||
|
OnBudgetAccounts []postgres.GetAccountsWithBalanceRow
|
||||||
|
OffBudgetAccounts []postgres.GetAccountsWithBalanceRow
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getImportantData(c *gin.Context) {
|
func (h *Handler) getImportantData(c *gin.Context) {
|
||||||
@ -23,21 +24,32 @@ func (h *Handler) getImportantData(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
budget, err := h.Service.DB.GetBudget(context.Background(), budgetUUID)
|
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusNotFound, err)
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts, err := h.Service.DB.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
|
accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var onBudgetAccounts, offBudgetAccounts []postgres.GetAccountsWithBalanceRow
|
||||||
|
for _, account := range accounts {
|
||||||
|
if account.OnBudget {
|
||||||
|
onBudgetAccounts = append(onBudgetAccounts, account)
|
||||||
|
} else {
|
||||||
|
offBudgetAccounts = append(offBudgetAccounts, account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
base := AlwaysNeededData{
|
base := AlwaysNeededData{
|
||||||
Accounts: accounts,
|
Accounts: accounts,
|
||||||
Budget: budget,
|
OnBudgetAccounts: onBudgetAccounts,
|
||||||
|
OffBudgetAccounts: offBudgetAccounts,
|
||||||
|
Budget: budget,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set("data", base)
|
c.Set("data", base)
|
||||||
|
@ -1,20 +1,22 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer"
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BudgetData struct {
|
type AllAccountsData struct {
|
||||||
AlwaysNeededData
|
AlwaysNeededData
|
||||||
|
Account *postgres.Account
|
||||||
|
Categories []postgres.GetCategoriesRow
|
||||||
Transactions []postgres.GetTransactionsForBudgetRow
|
Transactions []postgres.GetTransactionsForBudgetRow
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) budget(c *gin.Context) {
|
func (h *Handler) allAccounts(c *gin.Context) {
|
||||||
budgetID := c.Param("budgetid")
|
budgetID := c.Param("budgetid")
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -22,16 +24,41 @@ func (h *Handler) budget(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transactions, err := h.Service.DB.GetTransactionsForBudget(context.Background(), budgetUUID)
|
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions, err := h.Service.GetTransactionsForBudget(c.Request.Context(), budgetUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
d := BudgetData{
|
d := AllAccountsData{
|
||||||
c.MustGet("data").(AlwaysNeededData),
|
c.MustGet("data").(AlwaysNeededData),
|
||||||
|
&postgres.Account{
|
||||||
|
Name: "All accounts",
|
||||||
|
},
|
||||||
|
categories,
|
||||||
transactions,
|
transactions,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "budget.html", d)
|
c.HTML(http.StatusOK, "account.html", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) newBudget(c *gin.Context) {
|
||||||
|
budgetName, succ := c.GetPostForm("name")
|
||||||
|
if !succ {
|
||||||
|
c.AbortWithStatus(http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.MustGet("token").(budgeteer.Token).GetID()
|
||||||
|
_, err := h.Service.NewBudget(c.Request.Context(), budgetName, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -9,95 +8,175 @@ import (
|
|||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type BudgetingData struct {
|
type BudgetingData struct {
|
||||||
AlwaysNeededData
|
AlwaysNeededData
|
||||||
Categories []postgres.GetCategoriesWithBalanceRow
|
Categories []CategoryWithBalance
|
||||||
Date time.Time
|
AvailableBalance float64
|
||||||
Next time.Time
|
Date time.Time
|
||||||
Previous time.Time
|
Next time.Time
|
||||||
|
Previous time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) budgeting(c *gin.Context) {
|
func getFirstOfMonth(year, month int, location *time.Location) time.Time {
|
||||||
budgetID := c.Param("budgetid")
|
return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
}
|
||||||
if err != nil {
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
func getFirstOfMonthTime(date time.Time) time.Time {
|
||||||
|
var monthM time.Month
|
||||||
|
year, monthM, _ := date.Date()
|
||||||
|
month := int(monthM)
|
||||||
|
return getFirstOfMonth(year, month, date.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryWithBalance struct {
|
||||||
|
*postgres.GetCategoriesRow
|
||||||
|
Available float64
|
||||||
|
AvailableLastMonth float64
|
||||||
|
Activity float64
|
||||||
|
Assigned float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDate(c *gin.Context) (time.Time, error) {
|
||||||
var year, month int
|
var year, month int
|
||||||
yearString := c.Param("year")
|
yearString := c.Param("year")
|
||||||
monthString := c.Param("month")
|
monthString := c.Param("month")
|
||||||
if yearString != "" && monthString != "" {
|
if yearString == "" && monthString == "" {
|
||||||
year, err = strconv.Atoi(yearString)
|
return getFirstOfMonthTime(time.Now()), nil
|
||||||
if err != nil {
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
month, err = strconv.Atoi(monthString)
|
|
||||||
if err != nil {
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
var monthM time.Month
|
|
||||||
year, monthM, _ = now.Date()
|
|
||||||
month = int(monthM)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, now.Location())
|
year, err := strconv.Atoi(yearString)
|
||||||
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
|
|
||||||
firstOfPreviousMonth := firstOfMonth.AddDate(0, -1, 0)
|
|
||||||
|
|
||||||
params := postgres.GetCategoriesWithBalanceParams{
|
|
||||||
BudgetID: budgetUUID,
|
|
||||||
FromDate: firstOfMonth,
|
|
||||||
ToDate: firstOfNextMonth,
|
|
||||||
}
|
|
||||||
categories, err := h.Service.DB.GetCategoriesWithBalance(context.Background(), params)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
return time.Time{}, fmt.Errorf("parse year: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
month, err = strconv.Atoi(monthString)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("parse month: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return getFirstOfMonth(year, month, time.Now().Location()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) budgeting(c *gin.Context) {
|
||||||
|
alwaysNeededData := c.MustGet("data").(AlwaysNeededData)
|
||||||
|
budgetUUID := alwaysNeededData.Budget.ID
|
||||||
|
|
||||||
|
firstOfMonth, err := getDate(c)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
|
||||||
|
firstOfPreviousMonth := firstOfMonth.AddDate(0, -1, 0)
|
||||||
d := BudgetingData{
|
d := BudgetingData{
|
||||||
c.MustGet("data").(AlwaysNeededData),
|
AlwaysNeededData: alwaysNeededData,
|
||||||
categories,
|
Date: firstOfMonth,
|
||||||
firstOfMonth,
|
Next: firstOfNextMonth,
|
||||||
firstOfNextMonth,
|
Previous: firstOfPreviousMonth,
|
||||||
firstOfPreviousMonth,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
|
||||||
|
|
||||||
|
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("load balances: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip everything in the future
|
||||||
|
categoriesWithBalance, moneyUsed, err := h.calculateBalances(c, alwaysNeededData.Budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Categories = categoriesWithBalance
|
||||||
|
|
||||||
|
data := c.MustGet("data").(AlwaysNeededData)
|
||||||
|
var availableBalance float64 = 0
|
||||||
|
for _, cat := range categories {
|
||||||
|
if cat.ID != data.Budget.IncomeCategoryID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
availableBalance = moneyUsed
|
||||||
|
|
||||||
|
for _, bal := range cumultativeBalances {
|
||||||
|
if bal.CategoryID != cat.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bal.Date.Before(firstOfNextMonth) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
availableBalance += bal.Transactions.GetFloat64()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.AvailableBalance = availableBalance
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "budgeting.html", d)
|
c.HTML(http.StatusOK, "budgeting.html", d)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) clearBudget(c *gin.Context) {
|
func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, float64, error) {
|
||||||
budgetID := c.Param("budgetid")
|
categoriesWithBalance := []CategoryWithBalance{}
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
hiddenCategory := CategoryWithBalance{
|
||||||
if err != nil {
|
GetCategoriesRow: &postgres.GetCategoriesRow{
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
Name: "",
|
||||||
return
|
Group: "Hidden Categories",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := h.Service.DB.DeleteAllAssignments(c.Request.Context(), budgetUUID)
|
var moneyUsed float64 = 0
|
||||||
if err != nil {
|
for i := range categories {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
cat := &categories[i]
|
||||||
return
|
categoryWithBalance := CategoryWithBalance{
|
||||||
|
GetCategoriesRow: cat,
|
||||||
|
}
|
||||||
|
for _, bal := range cumultativeBalances {
|
||||||
|
if bal.CategoryID != cat.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bal.Date.Before(firstOfNextMonth) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
moneyUsed -= bal.Assignments.GetFloat64()
|
||||||
|
categoryWithBalance.Available += bal.Assignments.GetFloat64()
|
||||||
|
categoryWithBalance.Available += bal.Transactions.GetFloat64()
|
||||||
|
if categoryWithBalance.Available < 0 && bal.Date.Before(firstOfMonth) {
|
||||||
|
moneyUsed += categoryWithBalance.Available
|
||||||
|
categoryWithBalance.Available = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if bal.Date.Before(firstOfMonth) {
|
||||||
|
categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
|
||||||
|
} else if bal.Date.Before(firstOfNextMonth) {
|
||||||
|
categoryWithBalance.Activity = bal.Transactions.GetFloat64()
|
||||||
|
categoryWithBalance.Assigned = bal.Assignments.GetFloat64()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// do not show hidden categories
|
||||||
|
if cat.Group == "Hidden Categories" {
|
||||||
|
hiddenCategory.Available += categoryWithBalance.Available
|
||||||
|
hiddenCategory.AvailableLastMonth += categoryWithBalance.AvailableLastMonth
|
||||||
|
hiddenCategory.Activity += categoryWithBalance.Activity
|
||||||
|
hiddenCategory.Assigned += categoryWithBalance.Assigned
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if cat.ID == budget.IncomeCategoryID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Deleted %d assignments\n", rows)
|
categoriesWithBalance = append(categoriesWithBalance, hiddenCategory)
|
||||||
|
|
||||||
rows, err = h.Service.DB.DeleteAllTransactions(c.Request.Context(), budgetUUID)
|
return categoriesWithBalance, moneyUsed, nil
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Deleted %d transactions\n", rows)
|
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
func (h *Handler) dashboard(c *gin.Context) {
|
func (h *Handler) dashboard(c *gin.Context) {
|
||||||
userID := c.MustGet("token").(budgeteer.Token).GetID()
|
userID := c.MustGet("token").(budgeteer.Token).GetID()
|
||||||
budgets, err := h.Service.BudgetsForUser(userID)
|
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
132
http/http.go
132
http/http.go
@ -1,9 +1,9 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer"
|
"git.javil.eu/jacob1123/budgeteer"
|
||||||
@ -12,12 +12,11 @@ import (
|
|||||||
"git.javil.eu/jacob1123/budgeteer/web"
|
"git.javil.eu/jacob1123/budgeteer/web"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles incoming requests
|
// Handler handles incoming requests
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
Service *postgres.Repository
|
Service *postgres.Database
|
||||||
TokenVerifier budgeteer.TokenVerifier
|
TokenVerifier budgeteer.TokenVerifier
|
||||||
CredentialsVerifier *bcrypt.Verifier
|
CredentialsVerifier *bcrypt.Verifier
|
||||||
}
|
}
|
||||||
@ -30,6 +29,7 @@ const (
|
|||||||
// Serve starts the HTTP Server
|
// Serve starts the HTTP Server
|
||||||
func (h *Handler) Serve() {
|
func (h *Handler) Serve() {
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
|
router.FuncMap["now"] = time.Now
|
||||||
|
|
||||||
templates, err := NewTemplates(router.FuncMap)
|
templates, err := NewTemplates(router.FuncMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -42,6 +42,7 @@ func (h *Handler) Serve() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic("couldn't open static files")
|
panic("couldn't open static files")
|
||||||
}
|
}
|
||||||
|
router.Use(enableCachingForStaticFiles())
|
||||||
router.StaticFS("/static", http.FS(static))
|
router.StaticFS("/static", http.FS(static))
|
||||||
|
|
||||||
router.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) })
|
router.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) })
|
||||||
@ -58,11 +59,14 @@ func (h *Handler) Serve() {
|
|||||||
withBudget.Use(h.verifyLoginWithRedirect)
|
withBudget.Use(h.verifyLoginWithRedirect)
|
||||||
withBudget.Use(h.getImportantData)
|
withBudget.Use(h.getImportantData)
|
||||||
withBudget.GET("/budget/:budgetid", h.budgeting)
|
withBudget.GET("/budget/:budgetid", h.budgeting)
|
||||||
withBudget.GET("/budget/:budgetid/clear", h.clearBudget)
|
|
||||||
withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
|
withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
|
||||||
withBudget.GET("/budget/:budgetid/all-accounts", h.budget)
|
withBudget.GET("/budget/:budgetid/all-accounts", h.allAccounts)
|
||||||
withBudget.GET("/budget/:budgetid/accounts", h.accounts)
|
withBudget.GET("/budget/:budgetid/accounts", h.accounts)
|
||||||
withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
|
withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
|
||||||
|
withBudget.GET("/budget/:budgetid/settings", h.settings)
|
||||||
|
withBudget.GET("/budget/:budgetid/settings/clear", h.clearBudget)
|
||||||
|
withBudget.GET("/budget/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
|
||||||
|
withBudget.GET("/budget/:budgetid/transaction/:transactionid", h.transaction)
|
||||||
|
|
||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
|
|
||||||
@ -82,122 +86,16 @@ func (h *Handler) Serve() {
|
|||||||
|
|
||||||
transaction := authenticated.Group("/transaction")
|
transaction := authenticated.Group("/transaction")
|
||||||
transaction.POST("/new", h.newTransaction)
|
transaction.POST("/new", h.newTransaction)
|
||||||
|
transaction.POST("/:transactionid", h.newTransaction)
|
||||||
transaction.POST("/import/ynab", h.importYNAB)
|
transaction.POST("/import/ynab", h.importYNAB)
|
||||||
|
|
||||||
router.Run(":1323")
|
router.Run(":1323")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) importYNAB(c *gin.Context) {
|
func enableCachingForStaticFiles() gin.HandlerFunc {
|
||||||
budgetID, succ := c.GetPostForm("budget_id")
|
return func(c *gin.Context) {
|
||||||
if !succ {
|
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
|
c.Header("Cache-Control", "max-age=86400")
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithError(http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ynab, err := NewYNABImport(h.Service.DB, budgetUUID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionsFile, err := c.FormFile("transactions")
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactions, err := transactionsFile.Open()
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ynab.ImportTransactions(transactions)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assignmentsFile, err := c.FormFile("assignments")
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assignments, err := assignmentsFile.Open()
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ynab.ImportAssignments(assignments)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) newTransaction(c *gin.Context) {
|
|
||||||
transactionMemo, succ := c.GetPostForm("memo")
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionAccount, succ := c.GetPostForm("account_id")
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionAccountID, err := uuid.Parse(transactionAccount)
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionDate, succ := c.GetPostForm("date")
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionDateValue, err := time.Parse("2006-01-02", transactionDate)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
new := postgres.CreateTransactionParams{
|
|
||||||
Memo: transactionMemo,
|
|
||||||
Date: transactionDateValue,
|
|
||||||
Amount: postgres.Numeric{},
|
|
||||||
AccountID: transactionAccountID,
|
|
||||||
}
|
|
||||||
_, err = h.Service.DB.CreateTransaction(c.Request.Context(), new)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) newBudget(c *gin.Context) {
|
|
||||||
budgetName, succ := c.GetPostForm("name")
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := c.MustGet("token").(budgeteer.Token).GetID()
|
|
||||||
_, err := h.Service.NewBudget(budgetName, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -68,7 +68,7 @@ func (h *Handler) loginPost(c *gin.Context) {
|
|||||||
username, _ := c.GetPostForm("username")
|
username, _ := c.GetPostForm("username")
|
||||||
password, _ := c.GetPostForm("password")
|
password, _ := c.GetPostForm("password")
|
||||||
|
|
||||||
user, err := h.Service.DB.GetUserByUsername(context.Background(), username)
|
user, err := h.Service.GetUserByUsername(c.Request.Context(), username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusUnauthorized, err)
|
c.AbortWithError(http.StatusUnauthorized, err)
|
||||||
return
|
return
|
||||||
@ -84,7 +84,8 @@ func (h *Handler) loginPost(c *gin.Context) {
|
|||||||
c.AbortWithError(http.StatusUnauthorized, err)
|
c.AbortWithError(http.StatusUnauthorized, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = h.Service.DB.UpdateLastLogin(context.Background(), user.ID)
|
go h.Service.UpdateLastLogin(context.Background(), user.ID)
|
||||||
|
|
||||||
maxAge := (int)((expiration * time.Hour).Seconds())
|
maxAge := (int)((expiration * time.Hour).Seconds())
|
||||||
c.SetCookie(authCookie, t, maxAge, "", "", false, true)
|
c.SetCookie(authCookie, t, maxAge, "", "", false, true)
|
||||||
c.JSON(http.StatusOK, map[string]string{
|
c.JSON(http.StatusOK, map[string]string{
|
||||||
@ -97,7 +98,7 @@ func (h *Handler) registerPost(c *gin.Context) {
|
|||||||
password, _ := c.GetPostForm("password")
|
password, _ := c.GetPostForm("password")
|
||||||
name, _ := c.GetPostForm("name")
|
name, _ := c.GetPostForm("name")
|
||||||
|
|
||||||
_, err := h.Service.DB.GetUserByUsername(context.Background(), email)
|
_, err := h.Service.GetUserByUsername(c.Request.Context(), email)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.AbortWithStatus(http.StatusUnauthorized)
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@ -114,7 +115,7 @@ func (h *Handler) registerPost(c *gin.Context) {
|
|||||||
Password: hash,
|
Password: hash,
|
||||||
Email: email,
|
Email: email,
|
||||||
}
|
}
|
||||||
_, err = h.Service.DB.CreateUser(context.Background(), createUser)
|
_, err = h.Service.CreateUser(c.Request.Context(), createUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ type Templates struct {
|
|||||||
func NewTemplates(funcMap template.FuncMap) (*Templates, error) {
|
func NewTemplates(funcMap template.FuncMap) (*Templates, error) {
|
||||||
templates, err := fs.Glob(web.Templates, "*.tpl")
|
templates, err := fs.Glob(web.Templates, "*.tpl")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("glob: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &Templates{
|
result := &Templates{
|
||||||
|
62
http/transaction-edit.go
Normal file
62
http/transaction-edit.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TransactionData struct {
|
||||||
|
AlwaysNeededData
|
||||||
|
Transaction *postgres.Transaction
|
||||||
|
Account *postgres.Account
|
||||||
|
Categories []postgres.GetCategoriesRow
|
||||||
|
Payees []postgres.Payee
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) transaction(c *gin.Context) {
|
||||||
|
data := c.MustGet("data").(AlwaysNeededData)
|
||||||
|
|
||||||
|
transactionID := c.Param("transactionid")
|
||||||
|
transactionUUID, err := uuid.Parse(transactionID)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.Service.GetAccount(c.Request.Context(), transaction.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := h.Service.GetCategories(c.Request.Context(), data.Budget.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payees, err := h.Service.GetPayees(c.Request.Context(), data.Budget.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d := TransactionData{
|
||||||
|
data,
|
||||||
|
&transaction,
|
||||||
|
&account,
|
||||||
|
categories,
|
||||||
|
payees,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "transaction.html", d)
|
||||||
|
}
|
98
http/transaction.go
Normal file
98
http/transaction.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) newTransaction(c *gin.Context) {
|
||||||
|
transactionMemo, _ := c.GetPostForm("memo")
|
||||||
|
transactionAccountID, err := getUUID(c, "account_id")
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("account_id: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionCategoryID, err := getNullUUIDFromForm(c, "category_id")
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("category_id: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionPayeeID, err := getNullUUIDFromForm(c, "payee_id")
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("payee_id: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDate, succ := c.GetPostForm("date")
|
||||||
|
if !succ {
|
||||||
|
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("date missing"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDateValue, err := time.Parse("2006-01-02", transactionDate)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("date is not a valid date"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionAmount, succ := c.GetPostForm("amount")
|
||||||
|
if !succ {
|
||||||
|
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("amount missing"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := postgres.Numeric{}
|
||||||
|
amount.Set(transactionAmount)
|
||||||
|
|
||||||
|
transactionUUID, err := getNullUUIDFromParam(c, "transactionid")
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("parse transaction id: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !transactionUUID.Valid {
|
||||||
|
new := postgres.CreateTransactionParams{
|
||||||
|
Memo: transactionMemo,
|
||||||
|
Date: transactionDateValue,
|
||||||
|
Amount: amount,
|
||||||
|
AccountID: transactionAccountID,
|
||||||
|
PayeeID: transactionPayeeID,
|
||||||
|
CategoryID: transactionCategoryID,
|
||||||
|
}
|
||||||
|
_, err = h.Service.CreateTransaction(c.Request.Context(), new)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, delete := c.GetPostForm("delete")
|
||||||
|
if delete {
|
||||||
|
err = h.Service.DeleteTransaction(c.Request.Context(), transactionUUID.UUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("delete transaction: %w", err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
update := postgres.UpdateTransactionParams{
|
||||||
|
ID: transactionUUID.UUID,
|
||||||
|
Memo: transactionMemo,
|
||||||
|
Date: transactionDateValue,
|
||||||
|
Amount: amount,
|
||||||
|
AccountID: transactionAccountID,
|
||||||
|
PayeeID: transactionPayeeID,
|
||||||
|
CategoryID: transactionCategoryID,
|
||||||
|
}
|
||||||
|
err = h.Service.UpdateTransaction(c.Request.Context(), update)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update transaction: %w", err))
|
||||||
|
}
|
||||||
|
}
|
56
http/util.go
Normal file
56
http/util.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getUUID(c *gin.Context, name string) (uuid.UUID, error) {
|
||||||
|
value, succ := c.GetPostForm(name)
|
||||||
|
if !succ {
|
||||||
|
return uuid.UUID{}, fmt.Errorf("not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.UUID{}, fmt.Errorf("not a valid uuid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNullUUIDFromParam(c *gin.Context, name string) (uuid.NullUUID, error) {
|
||||||
|
value := c.Param(name)
|
||||||
|
if value == "" {
|
||||||
|
return uuid.NullUUID{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid.NullUUID{
|
||||||
|
UUID: id,
|
||||||
|
Valid: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNullUUIDFromForm(c *gin.Context, name string) (uuid.NullUUID, error) {
|
||||||
|
value, succ := c.GetPostForm(name)
|
||||||
|
if !succ || value == "" {
|
||||||
|
return uuid.NullUUID{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid.NullUUID{
|
||||||
|
UUID: id,
|
||||||
|
Valid: true,
|
||||||
|
}, nil
|
||||||
|
}
|
@ -1,299 +1,66 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type YNABImport struct {
|
func (h *Handler) importYNAB(c *gin.Context) {
|
||||||
Context context.Context
|
budgetID, succ := c.GetPostForm("budget_id")
|
||||||
accounts []postgres.Account
|
if !succ {
|
||||||
payees []postgres.Payee
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
|
||||||
categories []postgres.GetCategoriesRow
|
return
|
||||||
categoryGroups []postgres.CategoryGroup
|
}
|
||||||
queries *postgres.Queries
|
|
||||||
budgetID uuid.UUID
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
}
|
if !succ {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, err)
|
||||||
func NewYNABImport(q *postgres.Queries, budgetID uuid.UUID) (*YNABImport, error) {
|
return
|
||||||
accounts, err := q.GetAccounts(context.Background(), budgetID)
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
ynab, err := postgres.NewYNABImport(c.Request.Context(), h.Service.Queries, budgetUUID)
|
||||||
}
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
payees, err := q.GetPayees(context.Background(), budgetID)
|
return
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
|
||||||
}
|
transactionsFile, err := c.FormFile("transactions")
|
||||||
|
if err != nil {
|
||||||
categories, err := q.GetCategories(context.Background(), budgetID)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
if err != nil {
|
return
|
||||||
return nil, err
|
}
|
||||||
}
|
|
||||||
|
transactions, err := transactionsFile.Open()
|
||||||
categoryGroups, err := q.GetCategoryGroups(context.Background(), budgetID)
|
if err != nil {
|
||||||
if err != nil {
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
return nil, err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return &YNABImport{
|
err = ynab.ImportTransactions(transactions)
|
||||||
Context: context.Background(),
|
if err != nil {
|
||||||
accounts: accounts,
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
payees: payees,
|
return
|
||||||
categories: categories,
|
}
|
||||||
categoryGroups: categoryGroups,
|
|
||||||
queries: q,
|
assignmentsFile, err := c.FormFile("assignments")
|
||||||
budgetID: budgetID,
|
if err != nil {
|
||||||
}, nil
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
|
assignments, err := assignmentsFile.Open()
|
||||||
csv := csv.NewReader(r)
|
if err != nil {
|
||||||
csv.Comma = '\t'
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
csv.LazyQuotes = true
|
return
|
||||||
|
}
|
||||||
csvData, err := csv.ReadAll()
|
|
||||||
if err != nil {
|
err = ynab.ImportAssignments(assignments)
|
||||||
return fmt.Errorf("could not read from tsv: %w", err)
|
if err != nil {
|
||||||
}
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
count := 0
|
}
|
||||||
for _, record := range csvData[1:] {
|
|
||||||
//"Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available"
|
|
||||||
//"Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€
|
|
||||||
dateString := record[0]
|
|
||||||
date, err := time.Parse("Jan 2006", dateString)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse date %s: %w", dateString, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryGroup, categoryName := record[2], record[3] //also in 1 joined by :
|
|
||||||
category, err := ynab.GetCategory(categoryGroup, categoryName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
amountString := record[4]
|
|
||||||
amount, err := GetAmount(amountString, "0,00€")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse amount %s: %w", amountString, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if amount.Int.Int64() == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
assignment := postgres.CreateAssignmentParams{
|
|
||||||
Date: date,
|
|
||||||
CategoryID: category.UUID,
|
|
||||||
Amount: amount,
|
|
||||||
}
|
|
||||||
_, err = ynab.queries.CreateAssignment(ynab.Context, assignment)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not save assignment %v: %w", assignment, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Imported %d assignments\n", count)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
|
|
||||||
csv := csv.NewReader(r)
|
|
||||||
csv.Comma = '\t'
|
|
||||||
csv.LazyQuotes = true
|
|
||||||
|
|
||||||
csvData, err := csv.ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not read from tsv: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
for _, record := range csvData[1:] {
|
|
||||||
accountName := record[0]
|
|
||||||
account, err := ynab.GetAccount(accountName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not get account %s: %w", accountName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
//flag := record[1]
|
|
||||||
|
|
||||||
dateString := record[2]
|
|
||||||
date, err := time.Parse("02.01.2006", dateString)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse date %s: %w", dateString, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
payeeName := record[3]
|
|
||||||
payeeID, err := ynab.GetPayee(payeeName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not get payee %s: %w", payeeName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryGroup, categoryName := record[5], record[6] //also in 4 joined by :
|
|
||||||
category, err := ynab.GetCategory(categoryGroup, categoryName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
memo := record[7]
|
|
||||||
|
|
||||||
outflow := record[8]
|
|
||||||
inflow := record[9]
|
|
||||||
amount, err := GetAmount(inflow, outflow)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
//cleared := record[10]
|
|
||||||
|
|
||||||
transaction := postgres.CreateTransactionParams{
|
|
||||||
Date: date,
|
|
||||||
Memo: memo,
|
|
||||||
AccountID: account.ID,
|
|
||||||
PayeeID: payeeID,
|
|
||||||
CategoryID: category,
|
|
||||||
Amount: amount,
|
|
||||||
}
|
|
||||||
_, err = ynab.queries.CreateTransaction(ynab.Context, transaction)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not save transaction %v: %w", transaction, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Imported %d transactions\n", count)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimLastChar(s string) string {
|
|
||||||
r, size := utf8.DecodeLastRuneInString(s)
|
|
||||||
if r == utf8.RuneError && (size == 0 || size == 1) {
|
|
||||||
size = 0
|
|
||||||
}
|
|
||||||
return s[:len(s)-size]
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetAmount(inflow string, outflow string) (postgres.Numeric, error) {
|
|
||||||
// Remove trailing currency
|
|
||||||
inflow = strings.Replace(trimLastChar(inflow), ",", ".", 1)
|
|
||||||
outflow = strings.Replace(trimLastChar(outflow), ",", ".", 1)
|
|
||||||
|
|
||||||
num := postgres.Numeric{}
|
|
||||||
err := num.Set(inflow)
|
|
||||||
if err != nil {
|
|
||||||
return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if inflow is zero, use outflow
|
|
||||||
if num.Int.Int64() != 0 {
|
|
||||||
return num, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = num.Set("-" + outflow)
|
|
||||||
if err != nil {
|
|
||||||
return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err)
|
|
||||||
}
|
|
||||||
return num, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ynab *YNABImport) GetAccount(name string) (*postgres.Account, error) {
|
|
||||||
for _, acc := range ynab.accounts {
|
|
||||||
if acc.Name == name {
|
|
||||||
return &acc, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
account, err := ynab.queries.CreateAccount(ynab.Context, postgres.CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ynab.accounts = append(ynab.accounts, account)
|
|
||||||
return &account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
|
|
||||||
if name == "" {
|
|
||||||
return uuid.NullUUID{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pay := range ynab.payees {
|
|
||||||
if pay.Name == name {
|
|
||||||
return uuid.NullUUID{UUID: pay.ID, Valid: true}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payee, err := ynab.queries.CreatePayee(ynab.Context, postgres.CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ynab.payees = append(ynab.payees, payee)
|
|
||||||
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) {
|
|
||||||
if group == "" || name == "" {
|
|
||||||
return uuid.NullUUID{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, category := range ynab.categories {
|
|
||||||
if category.Name == name && category.Group == group {
|
|
||||||
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, categoryGroup := range ynab.categoryGroups {
|
|
||||||
if categoryGroup.Name == group {
|
|
||||||
createCategory := postgres.CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
|
|
||||||
category, err := ynab.queries.CreateCategory(ynab.Context, createCategory)
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
getCategory := postgres.GetCategoriesRow{
|
|
||||||
ID: category.ID,
|
|
||||||
CategoryGroupID: category.CategoryGroupID,
|
|
||||||
Name: category.Name,
|
|
||||||
Group: categoryGroup.Name,
|
|
||||||
}
|
|
||||||
ynab.categories = append(ynab.categories, getCategory)
|
|
||||||
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, postgres.CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID})
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, err
|
|
||||||
}
|
|
||||||
ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
|
|
||||||
|
|
||||||
category, err := ynab.queries.CreateCategory(ynab.Context, postgres.CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID})
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
getCategory := postgres.GetCategoriesRow{
|
|
||||||
ID: category.ID,
|
|
||||||
CategoryGroupID: category.CategoryGroupID,
|
|
||||||
Name: category.Name,
|
|
||||||
Group: categoryGroup.Name,
|
|
||||||
}
|
|
||||||
ynab.categories = append(ynab.categories, getCategory)
|
|
||||||
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ const createAccount = `-- name: CreateAccount :one
|
|||||||
INSERT INTO accounts
|
INSERT INTO accounts
|
||||||
(name, budget_id)
|
(name, budget_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
RETURNING id, budget_id, name
|
RETURNING id, budget_id, name, on_budget
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAccountParams struct {
|
type CreateAccountParams struct {
|
||||||
@ -24,24 +24,34 @@ type CreateAccountParams struct {
|
|||||||
func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) {
|
func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) {
|
||||||
row := q.db.QueryRowContext(ctx, createAccount, arg.Name, arg.BudgetID)
|
row := q.db.QueryRowContext(ctx, createAccount, arg.Name, arg.BudgetID)
|
||||||
var i Account
|
var i Account
|
||||||
err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.BudgetID,
|
||||||
|
&i.Name,
|
||||||
|
&i.OnBudget,
|
||||||
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAccount = `-- name: GetAccount :one
|
const getAccount = `-- name: GetAccount :one
|
||||||
SELECT accounts.id, accounts.budget_id, accounts.name FROM accounts
|
SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts
|
||||||
WHERE accounts.id = $1
|
WHERE accounts.id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error) {
|
func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getAccount, id)
|
row := q.db.QueryRowContext(ctx, getAccount, id)
|
||||||
var i Account
|
var i Account
|
||||||
err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.BudgetID,
|
||||||
|
&i.Name,
|
||||||
|
&i.OnBudget,
|
||||||
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAccounts = `-- name: GetAccounts :many
|
const getAccounts = `-- name: GetAccounts :many
|
||||||
SELECT accounts.id, accounts.budget_id, accounts.name FROM accounts
|
SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts
|
||||||
WHERE accounts.budget_id = $1
|
WHERE accounts.budget_id = $1
|
||||||
ORDER BY accounts.name
|
ORDER BY accounts.name
|
||||||
`
|
`
|
||||||
@ -55,7 +65,12 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
|
|||||||
var items []Account
|
var items []Account
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Account
|
var i Account
|
||||||
if err := rows.Scan(&i.ID, &i.BudgetID, &i.Name); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.BudgetID,
|
||||||
|
&i.Name,
|
||||||
|
&i.OnBudget,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@ -70,7 +85,7 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many
|
const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many
|
||||||
SELECT accounts.id, accounts.name, SUM(transactions.amount)::decimal(12,2) as balance
|
SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
|
||||||
FROM accounts
|
FROM accounts
|
||||||
LEFT JOIN transactions ON transactions.account_id = accounts.id
|
LEFT JOIN transactions ON transactions.account_id = accounts.id
|
||||||
WHERE accounts.budget_id = $1
|
WHERE accounts.budget_id = $1
|
||||||
@ -80,9 +95,10 @@ ORDER BY accounts.name
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetAccountsWithBalanceRow struct {
|
type GetAccountsWithBalanceRow struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
Balance Numeric
|
OnBudget bool
|
||||||
|
Balance Numeric
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID) ([]GetAccountsWithBalanceRow, error) {
|
func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID) ([]GetAccountsWithBalanceRow, error) {
|
||||||
@ -94,7 +110,12 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
|
|||||||
var items []GetAccountsWithBalanceRow
|
var items []GetAccountsWithBalanceRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetAccountsWithBalanceRow
|
var i GetAccountsWithBalanceRow
|
||||||
if err := rows.Scan(&i.ID, &i.Name, &i.Balance); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.OnBudget,
|
||||||
|
&i.Balance,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
@ -52,3 +52,37 @@ func (q *Queries) DeleteAllAssignments(ctx context.Context, budgetID uuid.UUID)
|
|||||||
}
|
}
|
||||||
return result.RowsAffected()
|
return result.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAssignmentsByMonthAndCategory = `-- name: GetAssignmentsByMonthAndCategory :many
|
||||||
|
SELECT date, category_id, budget_id, amount
|
||||||
|
FROM assignments_by_month
|
||||||
|
WHERE assignments_by_month.budget_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAssignmentsByMonthAndCategory(ctx context.Context, budgetID uuid.UUID) ([]AssignmentsByMonth, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAssignmentsByMonthAndCategory, budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []AssignmentsByMonth
|
||||||
|
for rows.Next() {
|
||||||
|
var i AssignmentsByMonth
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Date,
|
||||||
|
&i.CategoryID,
|
||||||
|
&i.BudgetID,
|
||||||
|
&i.Amount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
@ -5,38 +5,54 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createBudget = `-- name: CreateBudget :one
|
const createBudget = `-- name: CreateBudget :one
|
||||||
INSERT INTO budgets
|
INSERT INTO budgets
|
||||||
(name, last_modification)
|
(name, income_category_id, last_modification)
|
||||||
VALUES ($1, NOW())
|
VALUES ($1, $2, NOW())
|
||||||
RETURNING id, name, last_modification
|
RETURNING id, name, last_modification, income_category_id
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) CreateBudget(ctx context.Context, name string) (Budget, error) {
|
type CreateBudgetParams struct {
|
||||||
row := q.db.QueryRowContext(ctx, createBudget, name)
|
Name string
|
||||||
|
IncomeCategoryID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateBudget(ctx context.Context, arg CreateBudgetParams) (Budget, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createBudget, arg.Name, arg.IncomeCategoryID)
|
||||||
var i Budget
|
var i Budget
|
||||||
err := row.Scan(&i.ID, &i.Name, &i.LastModification)
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.LastModification,
|
||||||
|
&i.IncomeCategoryID,
|
||||||
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBudget = `-- name: GetBudget :one
|
const getBudget = `-- name: GetBudget :one
|
||||||
SELECT id, name, last_modification FROM budgets
|
SELECT id, name, last_modification, income_category_id FROM budgets
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetBudget(ctx context.Context, id uuid.UUID) (Budget, error) {
|
func (q *Queries) GetBudget(ctx context.Context, id uuid.UUID) (Budget, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getBudget, id)
|
row := q.db.QueryRowContext(ctx, getBudget, id)
|
||||||
var i Budget
|
var i Budget
|
||||||
err := row.Scan(&i.ID, &i.Name, &i.LastModification)
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.LastModification,
|
||||||
|
&i.IncomeCategoryID,
|
||||||
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBudgetsForUser = `-- name: GetBudgetsForUser :many
|
const getBudgetsForUser = `-- name: GetBudgetsForUser :many
|
||||||
SELECT budgets.id, budgets.name, budgets.last_modification FROM budgets
|
SELECT budgets.id, budgets.name, budgets.last_modification, budgets.income_category_id FROM budgets
|
||||||
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
|
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
|
||||||
WHERE user_budgets.user_id = $1
|
WHERE user_budgets.user_id = $1
|
||||||
`
|
`
|
||||||
@ -50,7 +66,12 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
|
|||||||
var items []Budget
|
var items []Budget
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Budget
|
var i Budget
|
||||||
if err := rows.Scan(&i.ID, &i.Name, &i.LastModification); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.LastModification,
|
||||||
|
&i.IncomeCategoryID,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@ -63,3 +84,42 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFirstActivity = `-- name: GetFirstActivity :one
|
||||||
|
SELECT MIN(dates.min_date)::date as min_date
|
||||||
|
FROM (
|
||||||
|
SELECT MIN(assignments.date) as min_date
|
||||||
|
FROM assignments
|
||||||
|
INNER JOIN categories ON categories.id = assignments.category_id
|
||||||
|
INNER JOIN category_groups ON category_groups.id = categories.category_group_id
|
||||||
|
WHERE category_groups.budget_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT MIN(transactions.date) as min_date
|
||||||
|
FROM transactions
|
||||||
|
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||||
|
WHERE accounts.budget_id = $1
|
||||||
|
) dates
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetFirstActivity(ctx context.Context, budgetID uuid.UUID) (time.Time, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getFirstActivity, budgetID)
|
||||||
|
var min_date time.Time
|
||||||
|
err := row.Scan(&min_date)
|
||||||
|
return min_date, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInflowCategory = `-- name: SetInflowCategory :exec
|
||||||
|
UPDATE budgets
|
||||||
|
SET income_category_id = $1
|
||||||
|
WHERE budgets.id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetInflowCategoryParams struct {
|
||||||
|
IncomeCategoryID uuid.UUID
|
||||||
|
ID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetInflowCategory(ctx context.Context, arg SetInflowCategoryParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setInflowCategory, arg.IncomeCategoryID, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
@ -2,39 +2,55 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Budget returns a budget for a given id.
|
// NewBudget creates a budget and adds it to the current user
|
||||||
func (s *Repository) Budget(id uuid.UUID) (*Budget, error) {
|
func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) {
|
||||||
budget, err := s.DB.GetBudget(context.Background(), id)
|
tx, err := s.BeginTx(context, &sql.TxOptions{})
|
||||||
|
q := s.WithTx(tx)
|
||||||
|
budget, err := q.CreateBudget(context, CreateBudgetParams{
|
||||||
|
Name: name,
|
||||||
|
IncomeCategoryID: uuid.New(),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("create budget: %w", err)
|
||||||
}
|
|
||||||
return &budget, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Repository) BudgetsForUser(id uuid.UUID) ([]Budget, error) {
|
|
||||||
budgets, err := s.DB.GetBudgetsForUser(context.Background(), id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return budgets, nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Repository) NewBudget(name string, userID uuid.UUID) (*Budget, error) {
|
|
||||||
budget, err := s.DB.CreateBudget(context.Background(), name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
|
ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
|
||||||
_, err = s.DB.LinkBudgetToUser(context.Background(), ub)
|
_, err = q.LinkBudgetToUser(context, ub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("link budget to user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
group, err := q.CreateCategoryGroup(context, CreateCategoryGroupParams{
|
||||||
|
Name: "Inflow",
|
||||||
|
BudgetID: budget.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create inflow category_group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := q.CreateCategory(context, CreateCategoryParams{
|
||||||
|
Name: "Ready to Assign",
|
||||||
|
CategoryGroupID: group.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create ready to assign category: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = q.SetInflowCategory(context, SetInflowCategoryParams{
|
||||||
|
IncomeCategoryID: cat.ID,
|
||||||
|
ID: budget.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("set inflow category: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
return &budget, nil
|
return &budget, nil
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@ -52,6 +51,7 @@ const getCategories = `-- name: GetCategories :many
|
|||||||
SELECT categories.id, categories.category_group_id, categories.name, category_groups.name as group FROM categories
|
SELECT categories.id, categories.category_group_id, categories.name, category_groups.name as group FROM categories
|
||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
||||||
WHERE category_groups.budget_id = $1
|
WHERE category_groups.budget_id = $1
|
||||||
|
ORDER BY category_groups.name, categories.name
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetCategoriesRow struct {
|
type GetCategoriesRow struct {
|
||||||
@ -89,81 +89,6 @@ func (q *Queries) GetCategories(ctx context.Context, budgetID uuid.UUID) ([]GetC
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCategoriesWithBalance = `-- name: GetCategoriesWithBalance :many
|
|
||||||
SELECT categories.id, categories.name, category_groups.name as group,
|
|
||||||
(COALESCE(
|
|
||||||
(
|
|
||||||
SELECT SUM(a_hist.amount)
|
|
||||||
FROM assignments a_hist
|
|
||||||
WHERE categories.id = a_hist.category_id
|
|
||||||
AND a_hist.date < $1
|
|
||||||
)
|
|
||||||
, 0)+COALESCE(
|
|
||||||
(
|
|
||||||
SELECT SUM(t_hist.amount)
|
|
||||||
FROM transactions t_hist
|
|
||||||
WHERE categories.id = t_hist.category_id
|
|
||||||
AND t_hist.date < $1
|
|
||||||
)
|
|
||||||
, 0))::decimal(12,2) as balance,
|
|
||||||
COALESCE(
|
|
||||||
(
|
|
||||||
SELECT SUM(t_this.amount)
|
|
||||||
FROM transactions t_this
|
|
||||||
WHERE categories.id = t_this.category_id
|
|
||||||
AND t_this.date BETWEEN $1 AND $2
|
|
||||||
)
|
|
||||||
, 0)::decimal(12,2) as activity
|
|
||||||
FROM categories
|
|
||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
|
||||||
WHERE category_groups.budget_id = $3
|
|
||||||
GROUP BY categories.id, categories.name, category_groups.name
|
|
||||||
ORDER BY category_groups.name, categories.name
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetCategoriesWithBalanceParams struct {
|
|
||||||
FromDate time.Time
|
|
||||||
ToDate time.Time
|
|
||||||
BudgetID uuid.UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetCategoriesWithBalanceRow struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
Name string
|
|
||||||
Group string
|
|
||||||
Balance Numeric
|
|
||||||
Activity Numeric
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetCategoriesWithBalance(ctx context.Context, arg GetCategoriesWithBalanceParams) ([]GetCategoriesWithBalanceRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getCategoriesWithBalance, arg.FromDate, arg.ToDate, arg.BudgetID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetCategoriesWithBalanceRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetCategoriesWithBalanceRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.Group,
|
|
||||||
&i.Balance,
|
|
||||||
&i.Activity,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCategoryGroups = `-- name: GetCategoryGroups :many
|
const getCategoryGroups = `-- name: GetCategoryGroups :many
|
||||||
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups
|
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups
|
||||||
WHERE category_groups.budget_id = $1
|
WHERE category_groups.budget_id = $1
|
||||||
|
@ -12,18 +12,26 @@ import (
|
|||||||
//go:embed schema/*.sql
|
//go:embed schema/*.sql
|
||||||
var migrations embed.FS
|
var migrations embed.FS
|
||||||
|
|
||||||
|
type Database struct {
|
||||||
|
*Queries
|
||||||
|
*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to a database
|
// Connect to a database
|
||||||
func Connect(server string, user string, password string, database string) (*Queries, *sql.DB, error) {
|
func Connect(server string, user string, password string, database string) (*Database, error) {
|
||||||
connString := fmt.Sprintf("postgres://%s:%s@%s/%s", user, password, server, database)
|
connString := fmt.Sprintf("postgres://%s:%s@%s/%s", user, password, server, database)
|
||||||
conn, err := sql.Open("pgx", connString)
|
conn, err := sql.Open("pgx", connString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, fmt.Errorf("open connection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
goose.SetBaseFS(migrations)
|
goose.SetBaseFS(migrations)
|
||||||
if err = goose.Up(conn, "schema"); err != nil {
|
if err = goose.Up(conn, "schema"); err != nil {
|
||||||
return nil, nil, err
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return New(conn), conn, nil
|
return &Database{
|
||||||
|
New(conn),
|
||||||
|
conn,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
60
postgres/cumultative-balances.sql.go
Normal file
60
postgres/cumultative-balances.sql.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// source: cumultative-balances.sql
|
||||||
|
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getCumultativeBalances = `-- name: GetCumultativeBalances :many
|
||||||
|
SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
|
||||||
|
COALESCE(ass.amount, 0)::decimal(12,2) as assignments, SUM(ass.amount) OVER (PARTITION BY ass.category_id ORDER BY ass.date)::decimal(12,2) as assignments_cum,
|
||||||
|
COALESCE(tra.amount, 0)::decimal(12,2) as transactions, SUM(tra.amount) OVER (PARTITION BY tra.category_id ORDER BY tra.date)::decimal(12,2) as transactions_cum
|
||||||
|
FROM assignments_by_month as ass
|
||||||
|
FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
|
||||||
|
WHERE (ass.budget_id IS NULL OR ass.budget_id = $1) AND (tra.budget_id IS NULL OR tra.budget_id = $1)
|
||||||
|
ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id)
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetCumultativeBalancesRow struct {
|
||||||
|
Date time.Time
|
||||||
|
CategoryID uuid.UUID
|
||||||
|
Assignments Numeric
|
||||||
|
AssignmentsCum Numeric
|
||||||
|
Transactions Numeric
|
||||||
|
TransactionsCum Numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID) ([]GetCumultativeBalancesRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getCumultativeBalances, budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetCumultativeBalancesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetCumultativeBalancesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Date,
|
||||||
|
&i.CategoryID,
|
||||||
|
&i.Assignments,
|
||||||
|
&i.AssignmentsCum,
|
||||||
|
&i.Transactions,
|
||||||
|
&i.TransactionsCum,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
@ -13,6 +13,7 @@ type Account struct {
|
|||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
BudgetID uuid.UUID
|
BudgetID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
|
OnBudget bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Assignment struct {
|
type Assignment struct {
|
||||||
@ -23,10 +24,18 @@ type Assignment struct {
|
|||||||
Amount Numeric
|
Amount Numeric
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AssignmentsByMonth struct {
|
||||||
|
Date time.Time
|
||||||
|
CategoryID uuid.UUID
|
||||||
|
BudgetID uuid.UUID
|
||||||
|
Amount int64
|
||||||
|
}
|
||||||
|
|
||||||
type Budget struct {
|
type Budget struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
LastModification sql.NullTime
|
LastModification sql.NullTime
|
||||||
|
IncomeCategoryID uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
type Category struct {
|
type Category struct {
|
||||||
@ -55,6 +64,14 @@ type Transaction struct {
|
|||||||
AccountID uuid.UUID
|
AccountID uuid.UUID
|
||||||
CategoryID uuid.NullUUID
|
CategoryID uuid.NullUUID
|
||||||
PayeeID uuid.NullUUID
|
PayeeID uuid.NullUUID
|
||||||
|
GroupID uuid.NullUUID
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransactionsByMonth struct {
|
||||||
|
Date time.Time
|
||||||
|
CategoryID uuid.NullUUID
|
||||||
|
BudgetID uuid.UUID
|
||||||
|
Amount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
@ -7,6 +7,9 @@ type Numeric struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n Numeric) GetFloat64() float64 {
|
func (n Numeric) GetFloat64() float64 {
|
||||||
|
if n.Status != pgtype.Present {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
var balance float64
|
var balance float64
|
||||||
err := n.AssignTo(&balance)
|
err := n.AssignTo(&balance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -15,7 +18,18 @@ func (n Numeric) GetFloat64() float64 {
|
|||||||
return balance
|
return balance
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n Numeric) GetPositive() bool {
|
func (n Numeric) IsPositive() bool {
|
||||||
|
if n.Status != pgtype.Present {
|
||||||
|
return true
|
||||||
|
}
|
||||||
float := n.GetFloat64()
|
float := n.GetFloat64()
|
||||||
return float >= 0
|
return float >= 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n Numeric) IsZero() bool {
|
||||||
|
if n.Status != pgtype.Present {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
float := n.GetFloat64()
|
||||||
|
return float == 0
|
||||||
|
}
|
||||||
|
@ -31,6 +31,7 @@ func (q *Queries) CreatePayee(ctx context.Context, arg CreatePayeeParams) (Payee
|
|||||||
const getPayees = `-- name: GetPayees :many
|
const getPayees = `-- name: GetPayees :many
|
||||||
SELECT payees.id, payees.budget_id, payees.name FROM payees
|
SELECT payees.id, payees.budget_id, payees.name FROM payees
|
||||||
WHERE payees.budget_id = $1
|
WHERE payees.budget_id = $1
|
||||||
|
ORDER BY name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, error) {
|
func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, error) {
|
||||||
|
@ -14,7 +14,7 @@ WHERE accounts.budget_id = $1
|
|||||||
ORDER BY accounts.name;
|
ORDER BY accounts.name;
|
||||||
|
|
||||||
-- name: GetAccountsWithBalance :many
|
-- name: GetAccountsWithBalance :many
|
||||||
SELECT accounts.id, accounts.name, SUM(transactions.amount)::decimal(12,2) as balance
|
SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
|
||||||
FROM accounts
|
FROM accounts
|
||||||
LEFT JOIN transactions ON transactions.account_id = accounts.id
|
LEFT JOIN transactions ON transactions.account_id = accounts.id
|
||||||
WHERE accounts.budget_id = $1
|
WHERE accounts.budget_id = $1
|
||||||
|
@ -11,3 +11,8 @@ DELETE FROM assignments
|
|||||||
USING categories
|
USING categories
|
||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
||||||
WHERE categories.id = assignments.category_id AND category_groups.budget_id = @budget_id;
|
WHERE categories.id = assignments.category_id AND category_groups.budget_id = @budget_id;
|
||||||
|
|
||||||
|
-- name: GetAssignmentsByMonthAndCategory :many
|
||||||
|
SELECT *
|
||||||
|
FROM assignments_by_month
|
||||||
|
WHERE assignments_by_month.budget_id = @budget_id;
|
@ -1,9 +1,14 @@
|
|||||||
-- name: CreateBudget :one
|
-- name: CreateBudget :one
|
||||||
INSERT INTO budgets
|
INSERT INTO budgets
|
||||||
(name, last_modification)
|
(name, income_category_id, last_modification)
|
||||||
VALUES ($1, NOW())
|
VALUES ($1, $2, NOW())
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: SetInflowCategory :exec
|
||||||
|
UPDATE budgets
|
||||||
|
SET income_category_id = $1
|
||||||
|
WHERE budgets.id = $2;
|
||||||
|
|
||||||
-- name: GetBudgetsForUser :many
|
-- name: GetBudgetsForUser :many
|
||||||
SELECT budgets.* FROM budgets
|
SELECT budgets.* FROM budgets
|
||||||
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
|
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
|
||||||
@ -11,4 +16,19 @@ WHERE user_budgets.user_id = $1;
|
|||||||
|
|
||||||
-- name: GetBudget :one
|
-- name: GetBudget :one
|
||||||
SELECT * FROM budgets
|
SELECT * FROM budgets
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetFirstActivity :one
|
||||||
|
SELECT MIN(dates.min_date)::date as min_date
|
||||||
|
FROM (
|
||||||
|
SELECT MIN(assignments.date) as min_date
|
||||||
|
FROM assignments
|
||||||
|
INNER JOIN categories ON categories.id = assignments.category_id
|
||||||
|
INNER JOIN category_groups ON category_groups.id = categories.category_group_id
|
||||||
|
WHERE category_groups.budget_id = @budget_id
|
||||||
|
UNION
|
||||||
|
SELECT MIN(transactions.date) as min_date
|
||||||
|
FROM transactions
|
||||||
|
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||||
|
WHERE accounts.budget_id = @budget_id
|
||||||
|
) dates;
|
@ -17,35 +17,5 @@ RETURNING *;
|
|||||||
-- name: GetCategories :many
|
-- name: GetCategories :many
|
||||||
SELECT categories.*, category_groups.name as group FROM categories
|
SELECT categories.*, category_groups.name as group FROM categories
|
||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
||||||
WHERE category_groups.budget_id = $1;
|
WHERE category_groups.budget_id = $1
|
||||||
|
|
||||||
-- name: GetCategoriesWithBalance :many
|
|
||||||
SELECT categories.id, categories.name, category_groups.name as group,
|
|
||||||
(COALESCE(
|
|
||||||
(
|
|
||||||
SELECT SUM(a_hist.amount)
|
|
||||||
FROM assignments a_hist
|
|
||||||
WHERE categories.id = a_hist.category_id
|
|
||||||
AND a_hist.date < @from_date
|
|
||||||
)
|
|
||||||
, 0)+COALESCE(
|
|
||||||
(
|
|
||||||
SELECT SUM(t_hist.amount)
|
|
||||||
FROM transactions t_hist
|
|
||||||
WHERE categories.id = t_hist.category_id
|
|
||||||
AND t_hist.date < @from_date
|
|
||||||
)
|
|
||||||
, 0))::decimal(12,2) as balance,
|
|
||||||
COALESCE(
|
|
||||||
(
|
|
||||||
SELECT SUM(t_this.amount)
|
|
||||||
FROM transactions t_this
|
|
||||||
WHERE categories.id = t_this.category_id
|
|
||||||
AND t_this.date BETWEEN @from_date AND @to_date
|
|
||||||
)
|
|
||||||
, 0)::decimal(12,2) as activity
|
|
||||||
FROM categories
|
|
||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
|
||||||
WHERE category_groups.budget_id = @budget_id
|
|
||||||
GROUP BY categories.id, categories.name, category_groups.name
|
|
||||||
ORDER BY category_groups.name, categories.name;
|
ORDER BY category_groups.name, categories.name;
|
8
postgres/queries/cumultative-balances.sql
Normal file
8
postgres/queries/cumultative-balances.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- name: GetCumultativeBalances :many
|
||||||
|
SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
|
||||||
|
COALESCE(ass.amount, 0)::decimal(12,2) as assignments, SUM(ass.amount) OVER (PARTITION BY ass.category_id ORDER BY ass.date)::decimal(12,2) as assignments_cum,
|
||||||
|
COALESCE(tra.amount, 0)::decimal(12,2) as transactions, SUM(tra.amount) OVER (PARTITION BY tra.category_id ORDER BY tra.date)::decimal(12,2) as transactions_cum
|
||||||
|
FROM assignments_by_month as ass
|
||||||
|
FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
|
||||||
|
WHERE (ass.budget_id IS NULL OR ass.budget_id = @budget_id) AND (tra.budget_id IS NULL OR tra.budget_id = @budget_id)
|
||||||
|
ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id);
|
@ -6,4 +6,5 @@ RETURNING *;
|
|||||||
|
|
||||||
-- name: GetPayees :many
|
-- name: GetPayees :many
|
||||||
SELECT payees.* FROM payees
|
SELECT payees.* FROM payees
|
||||||
WHERE payees.budget_id = $1;
|
WHERE payees.budget_id = $1
|
||||||
|
ORDER BY name;
|
@ -1,11 +1,29 @@
|
|||||||
|
-- name: GetTransaction :one
|
||||||
|
SELECT * FROM transactions
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: CreateTransaction :one
|
-- name: CreateTransaction :one
|
||||||
INSERT INTO transactions
|
INSERT INTO transactions
|
||||||
(date, memo, amount, account_id, payee_id, category_id)
|
(date, memo, amount, account_id, payee_id, category_id, group_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateTransaction :exec
|
||||||
|
UPDATE transactions
|
||||||
|
SET date = $1,
|
||||||
|
memo = $2,
|
||||||
|
amount = $3,
|
||||||
|
account_id = $4,
|
||||||
|
payee_id = $5,
|
||||||
|
category_id = $6
|
||||||
|
WHERE id = $7;
|
||||||
|
|
||||||
|
-- name: DeleteTransaction :exec
|
||||||
|
DELETE FROM transactions
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetTransactionsForBudget :many
|
-- name: GetTransactionsForBudget :many
|
||||||
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
|
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
|
||||||
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
|
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
|
||||||
FROM transactions
|
FROM transactions
|
||||||
INNER JOIN accounts ON accounts.id = transactions.account_id
|
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||||
@ -17,7 +35,7 @@ ORDER BY transactions.date DESC
|
|||||||
LIMIT 200;
|
LIMIT 200;
|
||||||
|
|
||||||
-- name: GetTransactionsForAccount :many
|
-- name: GetTransactionsForAccount :many
|
||||||
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
|
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
|
||||||
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
|
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
|
||||||
FROM transactions
|
FROM transactions
|
||||||
INNER JOIN accounts ON accounts.id = transactions.account_id
|
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||||
@ -32,4 +50,9 @@ LIMIT 200;
|
|||||||
DELETE FROM transactions
|
DELETE FROM transactions
|
||||||
USING accounts
|
USING accounts
|
||||||
WHERE accounts.budget_id = @budget_id
|
WHERE accounts.budget_id = @budget_id
|
||||||
AND accounts.id = transactions.account_id;
|
AND accounts.id = transactions.account_id;
|
||||||
|
|
||||||
|
-- name: GetTransactionsByMonthAndCategory :many
|
||||||
|
SELECT *
|
||||||
|
FROM transactions_by_month
|
||||||
|
WHERE transactions_by_month.budget_id = @budget_id;
|
@ -1,17 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import "database/sql"
|
|
||||||
|
|
||||||
// Repository represents a PostgreSQL implementation of all ModelServices
|
|
||||||
type Repository struct {
|
|
||||||
DB *Queries
|
|
||||||
LegacyDB *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRepository(queries *Queries, db *sql.DB) (*Repository, error) {
|
|
||||||
repo := &Repository{
|
|
||||||
DB: queries,
|
|
||||||
LegacyDB: db,
|
|
||||||
}
|
|
||||||
return repo, nil
|
|
||||||
}
|
|
9
postgres/schema/0002_budgets.sql
Normal file
9
postgres/schema/0002_budgets.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE budgets (
|
||||||
|
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
last_modification timestamp with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE budgets;
|
11
postgres/schema/0003_users.sql
Normal file
11
postgres/schema/0003_users.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE users (
|
||||||
|
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
email text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
password text NOT NULL,
|
||||||
|
last_login timestamp with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE users;
|
8
postgres/schema/0004_user_budgets.sql
Normal file
8
postgres/schema/0004_user_budgets.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE user_budgets (
|
||||||
|
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE user_budgets;
|
10
postgres/schema/0005_accounts.sql
Normal file
10
postgres/schema/0005_accounts.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
|
||||||
|
name varchar(50) NOT NULL,
|
||||||
|
on_budget boolean DEFAULT TRUE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE accounts;
|
9
postgres/schema/0006_payees.sql
Normal file
9
postgres/schema/0006_payees.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE payees (
|
||||||
|
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
|
||||||
|
name varchar(50) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE payees;
|
9
postgres/schema/0007_category-groups.sql
Normal file
9
postgres/schema/0007_category-groups.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE category_groups (
|
||||||
|
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
|
||||||
|
name varchar(50) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE category_groups;
|
12
postgres/schema/0008_categories.sql
Normal file
12
postgres/schema/0008_categories.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
category_group_id uuid NOT NULL REFERENCES category_groups (id) ON DELETE CASCADE,
|
||||||
|
name varchar(50) NOT NULL
|
||||||
|
);
|
||||||
|
ALTER TABLE budgets ADD COLUMN
|
||||||
|
income_category_id uuid NOT NULL REFERENCES categories (id) DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE budgets DROP COLUMN income_category_id;
|
||||||
|
DROP TABLE categories;
|
16
postgres/schema/0009_transactions.sql
Normal file
16
postgres/schema/0009_transactions.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE transactions (
|
||||||
|
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
date date NOT NULL,
|
||||||
|
memo text NOT NULL,
|
||||||
|
amount decimal(12,2) NOT NULL,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (id),
|
||||||
|
category_id uuid REFERENCES categories (id),
|
||||||
|
payee_id uuid REFERENCES payees (id)
|
||||||
|
);
|
||||||
|
ALTER TABLE "transactions" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");
|
||||||
|
ALTER TABLE "transactions" ADD FOREIGN KEY ("payee_id") REFERENCES "payees" ("id");
|
||||||
|
ALTER TABLE "transactions" ADD FOREIGN KEY ("category_id") REFERENCES "categories" ("id");
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE transactions;
|
17
postgres/schema/0011_views-for-months.sql
Normal file
17
postgres/schema/0011_views-for-months.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE VIEW transactions_by_month AS
|
||||||
|
SELECT date_trunc('month', transactions.date)::date as date, transactions.category_id, accounts.budget_id, SUM(amount) as amount
|
||||||
|
FROM transactions
|
||||||
|
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||||
|
GROUP BY date_trunc('month', transactions.date), transactions.category_id, accounts.budget_id;
|
||||||
|
|
||||||
|
CREATE VIEW assignments_by_month AS
|
||||||
|
SELECT date_trunc('month', assignments.date)::date as date, assignments.category_id, category_groups.budget_id, SUM(amount) as amount
|
||||||
|
FROM assignments
|
||||||
|
INNER JOIN categories ON categories.id = assignments.category_id
|
||||||
|
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
||||||
|
GROUP BY date_trunc('month', assignments.date), assignments.category_id, category_groups.budget_id;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP VIEW transactions_by_month;
|
||||||
|
DROP VIEW assignments_by_month;
|
5
postgres/schema/0012_add-group-id.sql
Normal file
5
postgres/schema/0012_add-group-id.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE transactions ADD COLUMN group_id uuid NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE transactions DROP COLUMN group_id;
|
@ -1,66 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
CREATE TABLE budgets (
|
|
||||||
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
|
||||||
name text NOT NULL,
|
|
||||||
last_modification timestamp with time zone
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
|
||||||
email text NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
password text NOT NULL,
|
|
||||||
last_login timestamp with time zone
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_budgets (
|
|
||||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
||||||
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE accounts (
|
|
||||||
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
|
||||||
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
|
|
||||||
name varchar(50) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE payees (
|
|
||||||
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
|
||||||
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
|
|
||||||
name varchar(50) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE category_groups (
|
|
||||||
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
|
||||||
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
|
|
||||||
name varchar(50) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE categories (
|
|
||||||
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
|
||||||
category_group_id uuid NOT NULL REFERENCES category_groups (id) ON DELETE CASCADE,
|
|
||||||
name varchar(50) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE transactions (
|
|
||||||
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
|
|
||||||
date date NOT NULL,
|
|
||||||
memo text NOT NULL,
|
|
||||||
amount decimal(12,2) NOT NULL,
|
|
||||||
account_id uuid NOT NULL REFERENCES accounts (id),
|
|
||||||
category_id uuid REFERENCES categories (id),
|
|
||||||
payee_id uuid REFERENCES payees (id)
|
|
||||||
);
|
|
||||||
ALTER TABLE "transactions" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");
|
|
||||||
ALTER TABLE "transactions" ADD FOREIGN KEY ("payee_id") REFERENCES "payees" ("id");
|
|
||||||
ALTER TABLE "transactions" ADD FOREIGN KEY ("category_id") REFERENCES "categories" ("id");
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
DROP TABLE transactions;
|
|
||||||
DROP TABLE accounts;
|
|
||||||
DROP TABLE payees;
|
|
||||||
DROP TABLE categories;
|
|
||||||
DROP TABLE category_groups;
|
|
||||||
DROP TABLE user_budgets;
|
|
||||||
DROP TABLE budgets;
|
|
||||||
DROP TABLE users;
|
|
@ -12,9 +12,9 @@ import (
|
|||||||
|
|
||||||
const createTransaction = `-- name: CreateTransaction :one
|
const createTransaction = `-- name: CreateTransaction :one
|
||||||
INSERT INTO transactions
|
INSERT INTO transactions
|
||||||
(date, memo, amount, account_id, payee_id, category_id)
|
(date, memo, amount, account_id, payee_id, category_id, group_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, date, memo, amount, account_id, category_id, payee_id
|
RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateTransactionParams struct {
|
type CreateTransactionParams struct {
|
||||||
@ -24,6 +24,7 @@ type CreateTransactionParams struct {
|
|||||||
AccountID uuid.UUID
|
AccountID uuid.UUID
|
||||||
PayeeID uuid.NullUUID
|
PayeeID uuid.NullUUID
|
||||||
CategoryID uuid.NullUUID
|
CategoryID uuid.NullUUID
|
||||||
|
GroupID uuid.NullUUID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) {
|
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) {
|
||||||
@ -34,6 +35,7 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
|
|||||||
arg.AccountID,
|
arg.AccountID,
|
||||||
arg.PayeeID,
|
arg.PayeeID,
|
||||||
arg.CategoryID,
|
arg.CategoryID,
|
||||||
|
arg.GroupID,
|
||||||
)
|
)
|
||||||
var i Transaction
|
var i Transaction
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@ -44,6 +46,7 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
|
|||||||
&i.AccountID,
|
&i.AccountID,
|
||||||
&i.CategoryID,
|
&i.CategoryID,
|
||||||
&i.PayeeID,
|
&i.PayeeID,
|
||||||
|
&i.GroupID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@ -63,8 +66,73 @@ func (q *Queries) DeleteAllTransactions(ctx context.Context, budgetID uuid.UUID)
|
|||||||
return result.RowsAffected()
|
return result.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteTransaction = `-- name: DeleteTransaction :exec
|
||||||
|
DELETE FROM transactions
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteTransaction, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTransaction = `-- name: GetTransaction :one
|
||||||
|
SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id FROM transactions
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (Transaction, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getTransaction, id)
|
||||||
|
var i Transaction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Date,
|
||||||
|
&i.Memo,
|
||||||
|
&i.Amount,
|
||||||
|
&i.AccountID,
|
||||||
|
&i.CategoryID,
|
||||||
|
&i.PayeeID,
|
||||||
|
&i.GroupID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTransactionsByMonthAndCategory = `-- name: GetTransactionsByMonthAndCategory :many
|
||||||
|
SELECT date, category_id, budget_id, amount
|
||||||
|
FROM transactions_by_month
|
||||||
|
WHERE transactions_by_month.budget_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetID uuid.UUID) ([]TransactionsByMonth, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getTransactionsByMonthAndCategory, budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []TransactionsByMonth
|
||||||
|
for rows.Next() {
|
||||||
|
var i TransactionsByMonth
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Date,
|
||||||
|
&i.CategoryID,
|
||||||
|
&i.BudgetID,
|
||||||
|
&i.Amount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
|
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
|
||||||
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
|
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
|
||||||
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
|
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
|
||||||
FROM transactions
|
FROM transactions
|
||||||
INNER JOIN accounts ON accounts.id = transactions.account_id
|
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||||
@ -81,6 +149,7 @@ type GetTransactionsForAccountRow struct {
|
|||||||
Date time.Time
|
Date time.Time
|
||||||
Memo string
|
Memo string
|
||||||
Amount Numeric
|
Amount Numeric
|
||||||
|
GroupID uuid.NullUUID
|
||||||
Account string
|
Account string
|
||||||
Payee string
|
Payee string
|
||||||
CategoryGroup string
|
CategoryGroup string
|
||||||
@ -101,6 +170,7 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
|
|||||||
&i.Date,
|
&i.Date,
|
||||||
&i.Memo,
|
&i.Memo,
|
||||||
&i.Amount,
|
&i.Amount,
|
||||||
|
&i.GroupID,
|
||||||
&i.Account,
|
&i.Account,
|
||||||
&i.Payee,
|
&i.Payee,
|
||||||
&i.CategoryGroup,
|
&i.CategoryGroup,
|
||||||
@ -120,7 +190,7 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many
|
const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many
|
||||||
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
|
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
|
||||||
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
|
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
|
||||||
FROM transactions
|
FROM transactions
|
||||||
INNER JOIN accounts ON accounts.id = transactions.account_id
|
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||||
@ -137,6 +207,7 @@ type GetTransactionsForBudgetRow struct {
|
|||||||
Date time.Time
|
Date time.Time
|
||||||
Memo string
|
Memo string
|
||||||
Amount Numeric
|
Amount Numeric
|
||||||
|
GroupID uuid.NullUUID
|
||||||
Account string
|
Account string
|
||||||
Payee string
|
Payee string
|
||||||
CategoryGroup string
|
CategoryGroup string
|
||||||
@ -157,6 +228,7 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
|
|||||||
&i.Date,
|
&i.Date,
|
||||||
&i.Memo,
|
&i.Memo,
|
||||||
&i.Amount,
|
&i.Amount,
|
||||||
|
&i.GroupID,
|
||||||
&i.Account,
|
&i.Account,
|
||||||
&i.Payee,
|
&i.Payee,
|
||||||
&i.CategoryGroup,
|
&i.CategoryGroup,
|
||||||
@ -174,3 +246,37 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTransactionParams struct {
|
||||||
|
Date time.Time
|
||||||
|
Memo string
|
||||||
|
Amount Numeric
|
||||||
|
AccountID uuid.UUID
|
||||||
|
PayeeID uuid.NullUUID
|
||||||
|
CategoryID uuid.NullUUID
|
||||||
|
ID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateTransaction,
|
||||||
|
arg.Date,
|
||||||
|
arg.Memo,
|
||||||
|
arg.Amount,
|
||||||
|
arg.AccountID,
|
||||||
|
arg.PayeeID,
|
||||||
|
arg.CategoryID,
|
||||||
|
arg.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
374
postgres/ynab-import.go
Normal file
374
postgres/ynab-import.go
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type YNABImport struct {
|
||||||
|
Context context.Context
|
||||||
|
accounts []Account
|
||||||
|
payees []Payee
|
||||||
|
categories []GetCategoriesRow
|
||||||
|
categoryGroups []CategoryGroup
|
||||||
|
queries *Queries
|
||||||
|
budgetID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewYNABImport(context context.Context, q *Queries, budgetID uuid.UUID) (*YNABImport, error) {
|
||||||
|
accounts, err := q.GetAccounts(context, budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payees, err := q.GetPayees(context, budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := q.GetCategories(context, budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryGroups, err := q.GetCategoryGroups(context, budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &YNABImport{
|
||||||
|
Context: context,
|
||||||
|
accounts: accounts,
|
||||||
|
payees: payees,
|
||||||
|
categories: categories,
|
||||||
|
categoryGroups: categoryGroups,
|
||||||
|
queries: q,
|
||||||
|
budgetID: budgetID,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportAssignments expects a TSV-file as exported by YNAB in the following format:
|
||||||
|
//"Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available"
|
||||||
|
//"Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€
|
||||||
|
//
|
||||||
|
// Activity and Available are not imported, since they are determined by the transactions and historic assignments
|
||||||
|
func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
|
||||||
|
csv := csv.NewReader(r)
|
||||||
|
csv.Comma = '\t'
|
||||||
|
csv.LazyQuotes = true
|
||||||
|
|
||||||
|
csvData, err := csv.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not read from tsv: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, record := range csvData[1:] {
|
||||||
|
|
||||||
|
dateString := record[0]
|
||||||
|
date, err := time.Parse("Jan 2006", dateString)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not parse date %s: %w", dateString, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryGroup, categoryName := record[2], record[3] //also in 1 joined by :
|
||||||
|
category, err := ynab.GetCategory(categoryGroup, categoryName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
amountString := record[4]
|
||||||
|
amount, err := GetAmount(amountString, "0,00€")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not parse amount %s: %w", amountString, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if amount.Int.Int64() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment := CreateAssignmentParams{
|
||||||
|
Date: date,
|
||||||
|
CategoryID: category.UUID,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
_, err = ynab.queries.CreateAssignment(ynab.Context, assignment)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not save assignment %v: %w", assignment, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Imported %d assignments\n", count)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transfer struct {
|
||||||
|
CreateTransactionParams
|
||||||
|
TransferToAccount *Account
|
||||||
|
FromAccount string
|
||||||
|
ToAccount string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportTransactions expects a TSV-file as exported by YNAB in the following format:
|
||||||
|
|
||||||
|
func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
|
||||||
|
csv := csv.NewReader(r)
|
||||||
|
csv.Comma = '\t'
|
||||||
|
csv.LazyQuotes = true
|
||||||
|
|
||||||
|
csvData, err := csv.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not read from tsv: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var openTransfers []Transfer
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, record := range csvData[1:] {
|
||||||
|
accountName := record[0]
|
||||||
|
account, err := ynab.GetAccount(accountName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get account %s: %w", accountName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//flag := record[1]
|
||||||
|
|
||||||
|
dateString := record[2]
|
||||||
|
date, err := time.Parse("02.01.2006", dateString)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not parse date %s: %w", dateString, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryGroup, categoryName := record[5], record[6] //also in 4 joined by :
|
||||||
|
category, err := ynab.GetCategory(categoryGroup, categoryName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memo := record[7]
|
||||||
|
|
||||||
|
outflow := record[8]
|
||||||
|
inflow := record[9]
|
||||||
|
amount, err := GetAmount(inflow, outflow)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction := CreateTransactionParams{
|
||||||
|
Date: date,
|
||||||
|
Memo: memo,
|
||||||
|
AccountID: account.ID,
|
||||||
|
CategoryID: category,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
|
||||||
|
payeeName := record[3]
|
||||||
|
if strings.HasPrefix(payeeName, "Transfer : ") {
|
||||||
|
// Transaction is a transfer to
|
||||||
|
transferToAccountName := payeeName[11:]
|
||||||
|
transferToAccount, err := ynab.GetAccount(transferToAccountName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Could not get transfer account %s: %w", transferToAccountName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer := Transfer{
|
||||||
|
transaction,
|
||||||
|
transferToAccount,
|
||||||
|
accountName,
|
||||||
|
transferToAccountName,
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, openTransfer := range openTransfers {
|
||||||
|
if openTransfer.TransferToAccount.ID != transfer.AccountID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if openTransfer.AccountID != transfer.TransferToAccount.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if openTransfer.Amount.GetFloat64() != -1*transfer.Amount.GetFloat64() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64())
|
||||||
|
openTransfers[i] = openTransfers[len(openTransfers)-1]
|
||||||
|
openTransfers = openTransfers[:len(openTransfers)-1]
|
||||||
|
found = true
|
||||||
|
|
||||||
|
groupID := uuid.New()
|
||||||
|
transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
|
||||||
|
openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
|
||||||
|
|
||||||
|
_, err = ynab.queries.CreateTransaction(ynab.Context, transfer.CreateTransactionParams)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not save transaction %v: %w", transfer.CreateTransactionParams, err)
|
||||||
|
}
|
||||||
|
_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
openTransfers = append(openTransfers, transfer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payeeID, err := ynab.GetPayee(payeeName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get payee %s: %w", payeeName, err)
|
||||||
|
}
|
||||||
|
transaction.PayeeID = payeeID
|
||||||
|
|
||||||
|
_, err = ynab.queries.CreateTransaction(ynab.Context, transaction)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not save transaction %v: %w", transaction, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//status := record[10]
|
||||||
|
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, openTransfer := range openTransfers {
|
||||||
|
fmt.Printf("Saving unmatched transfer from %s to %s on %s over %f as regular transaction\n", openTransfer.FromAccount, openTransfer.ToAccount, openTransfer.Date, openTransfer.Amount.GetFloat64())
|
||||||
|
_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
fmt.Printf("Imported %d transactions\n", count)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimLastChar(s string) string {
|
||||||
|
r, size := utf8.DecodeLastRuneInString(s)
|
||||||
|
if r == utf8.RuneError && (size == 0 || size == 1) {
|
||||||
|
size = 0
|
||||||
|
}
|
||||||
|
return s[:len(s)-size]
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAmount(inflow string, outflow string) (Numeric, error) {
|
||||||
|
// Remove trailing currency
|
||||||
|
inflow = strings.Replace(trimLastChar(inflow), ",", ".", 1)
|
||||||
|
outflow = strings.Replace(trimLastChar(outflow), ",", ".", 1)
|
||||||
|
|
||||||
|
num := Numeric{}
|
||||||
|
err := num.Set(inflow)
|
||||||
|
if err != nil {
|
||||||
|
return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if inflow is zero, use outflow
|
||||||
|
if num.Int.Int64() != 0 {
|
||||||
|
return num, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = num.Set("-" + outflow)
|
||||||
|
if err != nil {
|
||||||
|
return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err)
|
||||||
|
}
|
||||||
|
return num, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ynab *YNABImport) GetAccount(name string) (*Account, error) {
|
||||||
|
for _, acc := range ynab.accounts {
|
||||||
|
if acc.Name == name {
|
||||||
|
return &acc, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := ynab.queries.CreateAccount(ynab.Context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ynab.accounts = append(ynab.accounts, account)
|
||||||
|
return &account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
|
||||||
|
if name == "" {
|
||||||
|
return uuid.NullUUID{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pay := range ynab.payees {
|
||||||
|
if pay.Name == name {
|
||||||
|
return uuid.NullUUID{UUID: pay.ID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payee, err := ynab.queries.CreatePayee(ynab.Context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ynab.payees = append(ynab.payees, payee)
|
||||||
|
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) {
|
||||||
|
if group == "" || name == "" {
|
||||||
|
return uuid.NullUUID{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, category := range ynab.categories {
|
||||||
|
if category.Name == name && category.Group == group {
|
||||||
|
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, categoryGroup := range ynab.categoryGroups {
|
||||||
|
if categoryGroup.Name == group {
|
||||||
|
createCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
|
||||||
|
category, err := ynab.queries.CreateCategory(ynab.Context, createCategory)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategory := GetCategoriesRow{
|
||||||
|
ID: category.ID,
|
||||||
|
CategoryGroupID: category.CategoryGroupID,
|
||||||
|
Name: category.Name,
|
||||||
|
Group: categoryGroup.Name,
|
||||||
|
}
|
||||||
|
ynab.categories = append(ynab.categories, getCategory)
|
||||||
|
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID})
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, err
|
||||||
|
}
|
||||||
|
ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
|
||||||
|
|
||||||
|
category, err := ynab.queries.CreateCategory(ynab.Context, CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID})
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategory := GetCategoriesRow{
|
||||||
|
ID: category.ID,
|
||||||
|
CategoryGroupID: category.CategoryGroupID,
|
||||||
|
Name: category.Name,
|
||||||
|
Group: categoryGroup.Name,
|
||||||
|
}
|
||||||
|
ynab.categories = append(ynab.categories, getCategory)
|
||||||
|
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
||||||
|
}
|
@ -3,18 +3,18 @@
|
|||||||
{{define "title"}}{{.Account.Name}}{{end}}
|
{{define "title"}}{{.Account.Name}}{{end}}
|
||||||
|
|
||||||
{{define "new"}}
|
{{define "new"}}
|
||||||
{{template "transaction-new"}}
|
{{template "transaction-new" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<div class="budget-item">
|
<div class="budget-item">
|
||||||
<a href="#newtransactionmodal" data-toggle="modal" data-target="#newtransactionmodal">New Transaction</a>
|
<a href="#newtransactionmodal" data-bs-toggle="modal" data-bs-target="#newtransactionmodal">New Transaction</a>
|
||||||
<span class="time"></span>
|
<span class="time"></span>
|
||||||
</div>
|
</div>
|
||||||
<table class="container col-lg-12" id="content">
|
<table class="container col-lg-12" id="content">
|
||||||
{{range .Transactions}}
|
{{range .Transactions}}
|
||||||
<tr>
|
<tr class="{{if .Date.After now}}future{{end}}">
|
||||||
<td>{{.Date}}</td>
|
<td>{{.Date.Format "02.01.2006"}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{.Account}}
|
{{.Account}}
|
||||||
</td>
|
</td>
|
||||||
@ -27,7 +27,10 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="transaction/{{.ID}}">{{.Memo}}</a>
|
{{if .GroupID.Valid}}☀{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/budget/{{$.Budget.ID}}/transaction/{{.ID}}">{{.Memo}}</a>
|
||||||
</td>
|
</td>
|
||||||
{{template "amount-cell" .Amount}}
|
{{template "amount-cell" .Amount}}
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -1,11 +1,23 @@
|
|||||||
{{define "amount"}}
|
{{define "amount"}}
|
||||||
<span class="right {{if .GetPositive}}{{else}}negative{{end}}">
|
<span class="right {{if .IsZero}}zero{{else if not .IsPositive}}negative{{end}}">
|
||||||
{{printf "%.2f" .GetFloat64}}
|
{{printf "%.2f" .GetFloat64}}
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "amount-cell"}}
|
{{define "amount-cell"}}
|
||||||
<td class="right {{if .GetPositive}}{{else}}negative{{end}}">
|
<td class="right {{if .IsZero}}zero{{else if not .IsPositive}}negative{{end}}">
|
||||||
{{printf "%.2f" .GetFloat64}}
|
{{printf "%.2f" .GetFloat64}}
|
||||||
</td>
|
</td>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "amountf64"}}
|
||||||
|
<span class="right {{if eq . 0.0}}zero{{else if lt . 0.0}}negative{{end}}">
|
||||||
|
{{printf "%.2f" .}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "amountf64-cell"}}
|
||||||
|
<td class="right {{if eq . 0.0}}zero{{else if lt . 0.0}}negative{{end}}">
|
||||||
|
{{printf "%.2f" .}}
|
||||||
|
</td>
|
||||||
{{end}}
|
{{end}}
|
25
web/base.tpl
25
web/base.tpl
@ -6,7 +6,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
|
|
||||||
<link href="/static/css/bootstrap.min.css" rel="stylesheet" />
|
<link href="/static/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
<link href="/static/css/bootstrap-theme.min.css" rel="stylesheet" />
|
|
||||||
<link href="/static/css/main.css" rel="stylesheet" />
|
<link href="/static/css/main.css" rel="stylesheet" />
|
||||||
|
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
|
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
|
||||||
@ -19,19 +18,21 @@
|
|||||||
{{block "more-head" .}}{{end}}
|
{{block "more-head" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="sidebar">
|
<div id="wrapper">
|
||||||
{{block "sidebar" .}}
|
<div id="sidebar">
|
||||||
{{template "budget-sidebar" .}}
|
{{block "sidebar" .}}
|
||||||
{{end}}
|
{{template "budget-sidebar" .}}
|
||||||
</div>
|
{{end}}
|
||||||
<div id="content">
|
|
||||||
<div class="container" id="head">
|
|
||||||
{{template "title" .}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="container col-lg-12" id="content">
|
<div id="content">
|
||||||
{{template "main" .}}
|
<div class="container" id="head">
|
||||||
|
{{template "title" .}}
|
||||||
|
</div>
|
||||||
|
<div class="container col-lg-12" id="content">
|
||||||
|
{{template "main" .}}
|
||||||
|
</div>
|
||||||
|
{{block "new" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{block "new" .}}{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{{define "budget-new"}}
|
{{define "budget-new"}}
|
||||||
<div id="newbudgetmodal" class="modal fade">
|
<div id="newbudgetmodal" class="modal fade" role="dialog">
|
||||||
<div class="modal-dialog" role="document">
|
<div class="modal-dialog" role="document">
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
@ -14,7 +14,7 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">New Budget</h5>
|
<h5 class="modal-title">New Budget</h5>
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
<input type="submit" class="btn btn-primary" value="Create" class="form-control" />
|
<input type="submit" class="btn btn-primary" value="Create" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{{define "budget-sidebar"}}
|
{{define "budget-sidebar"}}
|
||||||
|
<h1><a href="/dashboard">⌂</a> {{.Budget.Name}}</h1>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/budget/{{.Budget.ID}}">Budget</a></li>
|
<li><a href="/budget/{{.Budget.ID}}">Budget</a></li>
|
||||||
<li>Reports (Coming Soon)</li>
|
<li>Reports (Coming Soon)</li>
|
||||||
@ -6,28 +7,42 @@
|
|||||||
<li>
|
<li>
|
||||||
On-Budget Accounts
|
On-Budget Accounts
|
||||||
<ul class="two-valued">
|
<ul class="two-valued">
|
||||||
{{range .Accounts}}
|
{{- range .OnBudgetAccounts}}
|
||||||
<li>
|
|
||||||
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
|
|
||||||
{{template "amount" .Balance}}
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/budget/{{$.Budget.ID}}/accounts">Edit accounts</a>
|
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
|
||||||
|
{{- template "amount" .Balance}}
|
||||||
</li>
|
</li>
|
||||||
|
{{- end}}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Off-Budget Accounts
|
Off-Budget Accounts
|
||||||
|
<ul class="two-valued">
|
||||||
|
{{- range .OffBudgetAccounts}}
|
||||||
|
<li>
|
||||||
|
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
|
||||||
|
{{template "amount" .Balance -}}
|
||||||
|
</li>
|
||||||
|
{{- end}}
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Closed Accounts
|
Closed Accounts
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/budget/{{$.Budget.ID}}/accounts">Edit accounts</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
+ Add Account
|
+ Add Account
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/admin">Settings</a>
|
<a href="/budget/{{.Budget.ID}}/settings">Budget-Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/api/v1/user/logout">Logout</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
@ -1,31 +0,0 @@
|
|||||||
{{template "base" .}}
|
|
||||||
|
|
||||||
{{define "title"}}Budget{{end}}
|
|
||||||
|
|
||||||
{{define "new"}}
|
|
||||||
{{template "transaction-new"}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "main"}}
|
|
||||||
<div class="budget-item">
|
|
||||||
<a href="#newtransactionmodal" data-toggle="modal" data-target="#newtransactionmodal">New Transaction</a>
|
|
||||||
<span class="time"></span>
|
|
||||||
</div>
|
|
||||||
<table class="container col-lg-12" id="content">
|
|
||||||
{{range .Transactions}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.Date}}</td>
|
|
||||||
<td>
|
|
||||||
{{.Account}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{.Payee}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="transaction/{{.ID}}">{{.Memo}}</a>
|
|
||||||
</td>
|
|
||||||
{{template "amount-cell" .Amount}}
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
@ -6,12 +6,11 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "new"}}
|
{{define "new"}}
|
||||||
{{template "transaction-new"}}
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<div class="budget-item">
|
<div class="budget-item">
|
||||||
<a href="#newtransactionmodal" data-toggle="modal" data-target="#newtransactionmodal">New Transaction</a>
|
<a href="#newtransactionmodal" data-bs-toggle="modal" data-bs-target="#newtransactionmodal">New Transaction</a>
|
||||||
<span class="time"></span>
|
<span class="time"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -19,14 +18,19 @@
|
|||||||
<a href="{{printf "/budget/%s" .Budget.ID}}">Current Month</a> -
|
<a href="{{printf "/budget/%s" .Budget.ID}}">Current Month</a> -
|
||||||
<a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Next.Year .Next.Month}}">Next Month</a>
|
<a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Next.Year .Next.Month}}">Next Month</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Available Balance: </span>{{template "amountf64" .AvailableBalance}}
|
||||||
|
</div>
|
||||||
<table class="container col-lg-12" id="content">
|
<table class="container col-lg-12" id="content">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Group</th>
|
<th>Group</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>Balance</th>
|
<th>Leftover</th>
|
||||||
|
<th>Assigned</th>
|
||||||
<th>Activity</th>
|
<th>Activity</th>
|
||||||
|
<th>Available</th>
|
||||||
</tr>
|
</tr>
|
||||||
{{range .Categories}}
|
{{range .Categories}}
|
||||||
<tr>
|
<tr>
|
||||||
@ -36,8 +40,10 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
</td>
|
</td>
|
||||||
{{template "amount-cell" .Balance}}
|
{{template "amountf64-cell" .AvailableLastMonth}}
|
||||||
{{template "amount-cell" .Activity}}
|
{{template "amountf64-cell" .Assigned}}
|
||||||
|
{{template "amountf64-cell" .Activity}}
|
||||||
|
{{template "amountf64-cell" .Available}}
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</table>
|
</table>
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="budget-item">
|
<div class="budget-item">
|
||||||
<a href="#newbudgetmodal" data-toggle="modal" data-target="#newbudgetmodal">New Budget</a>
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newbudgetmodal">New Budget</a>
|
||||||
<span class="time"></span>
|
<span class="time"></span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
32
web/settings.html
Normal file
32
web/settings.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{{define "title"}}
|
||||||
|
Settings for Budget "{{.Budget.Name}}"
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<h1>Danger Zone</h1>
|
||||||
|
<div class="budget-item">
|
||||||
|
<a href="/budget/{{.Budget.ID}}/settings/clear">Clear budget</a>
|
||||||
|
<p>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</p>
|
||||||
|
</div>
|
||||||
|
<div class="budget-item">
|
||||||
|
<a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
|
||||||
|
<p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p>
|
||||||
|
</div>
|
||||||
|
<div class="budget-item">
|
||||||
|
<form method="POST" action="/api/v1/transaction/import/ynab" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="budget_id" value="{{.Budget.ID}}" />
|
||||||
|
<label for="transactions_file">
|
||||||
|
Transaktionen:
|
||||||
|
<input type="file" name="transactions" accept="text/*" />
|
||||||
|
</label>
|
||||||
|
<br />
|
||||||
|
<label for="assignments_file">
|
||||||
|
Budget:
|
||||||
|
<input type="file" name="assignments" accept="text/*" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Importieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
587
web/static/css/bootstrap-theme.css
vendored
587
web/static/css/bootstrap-theme.css
vendored
@ -1,587 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Bootstrap v3.3.7 (http://getbootstrap.com)
|
|
||||||
* Copyright 2011-2016 Twitter, Inc.
|
|
||||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
|
||||||
*/
|
|
||||||
.btn-default,
|
|
||||||
.btn-primary,
|
|
||||||
.btn-success,
|
|
||||||
.btn-info,
|
|
||||||
.btn-warning,
|
|
||||||
.btn-danger {
|
|
||||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
|
|
||||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.btn-default:active,
|
|
||||||
.btn-primary:active,
|
|
||||||
.btn-success:active,
|
|
||||||
.btn-info:active,
|
|
||||||
.btn-warning:active,
|
|
||||||
.btn-danger:active,
|
|
||||||
.btn-default.active,
|
|
||||||
.btn-primary.active,
|
|
||||||
.btn-success.active,
|
|
||||||
.btn-info.active,
|
|
||||||
.btn-warning.active,
|
|
||||||
.btn-danger.active {
|
|
||||||
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
|
||||||
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
|
||||||
}
|
|
||||||
.btn-default.disabled,
|
|
||||||
.btn-primary.disabled,
|
|
||||||
.btn-success.disabled,
|
|
||||||
.btn-info.disabled,
|
|
||||||
.btn-warning.disabled,
|
|
||||||
.btn-danger.disabled,
|
|
||||||
.btn-default[disabled],
|
|
||||||
.btn-primary[disabled],
|
|
||||||
.btn-success[disabled],
|
|
||||||
.btn-info[disabled],
|
|
||||||
.btn-warning[disabled],
|
|
||||||
.btn-danger[disabled],
|
|
||||||
fieldset[disabled] .btn-default,
|
|
||||||
fieldset[disabled] .btn-primary,
|
|
||||||
fieldset[disabled] .btn-success,
|
|
||||||
fieldset[disabled] .btn-info,
|
|
||||||
fieldset[disabled] .btn-warning,
|
|
||||||
fieldset[disabled] .btn-danger {
|
|
||||||
-webkit-box-shadow: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.btn-default .badge,
|
|
||||||
.btn-primary .badge,
|
|
||||||
.btn-success .badge,
|
|
||||||
.btn-info .badge,
|
|
||||||
.btn-warning .badge,
|
|
||||||
.btn-danger .badge {
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
.btn:active,
|
|
||||||
.btn.active {
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-default {
|
|
||||||
text-shadow: 0 1px 0 #fff;
|
|
||||||
background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
|
|
||||||
background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #dbdbdb;
|
|
||||||
border-color: #ccc;
|
|
||||||
}
|
|
||||||
.btn-default:hover,
|
|
||||||
.btn-default:focus {
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-default:active,
|
|
||||||
.btn-default.active {
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
border-color: #dbdbdb;
|
|
||||||
}
|
|
||||||
.btn-default.disabled,
|
|
||||||
.btn-default[disabled],
|
|
||||||
fieldset[disabled] .btn-default,
|
|
||||||
.btn-default.disabled:hover,
|
|
||||||
.btn-default[disabled]:hover,
|
|
||||||
fieldset[disabled] .btn-default:hover,
|
|
||||||
.btn-default.disabled:focus,
|
|
||||||
.btn-default[disabled]:focus,
|
|
||||||
fieldset[disabled] .btn-default:focus,
|
|
||||||
.btn-default.disabled.focus,
|
|
||||||
.btn-default[disabled].focus,
|
|
||||||
fieldset[disabled] .btn-default.focus,
|
|
||||||
.btn-default.disabled:active,
|
|
||||||
.btn-default[disabled]:active,
|
|
||||||
fieldset[disabled] .btn-default:active,
|
|
||||||
.btn-default.disabled.active,
|
|
||||||
.btn-default[disabled].active,
|
|
||||||
fieldset[disabled] .btn-default.active {
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-primary {
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #245580;
|
|
||||||
}
|
|
||||||
.btn-primary:hover,
|
|
||||||
.btn-primary:focus {
|
|
||||||
background-color: #265a88;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-primary:active,
|
|
||||||
.btn-primary.active {
|
|
||||||
background-color: #265a88;
|
|
||||||
border-color: #245580;
|
|
||||||
}
|
|
||||||
.btn-primary.disabled,
|
|
||||||
.btn-primary[disabled],
|
|
||||||
fieldset[disabled] .btn-primary,
|
|
||||||
.btn-primary.disabled:hover,
|
|
||||||
.btn-primary[disabled]:hover,
|
|
||||||
fieldset[disabled] .btn-primary:hover,
|
|
||||||
.btn-primary.disabled:focus,
|
|
||||||
.btn-primary[disabled]:focus,
|
|
||||||
fieldset[disabled] .btn-primary:focus,
|
|
||||||
.btn-primary.disabled.focus,
|
|
||||||
.btn-primary[disabled].focus,
|
|
||||||
fieldset[disabled] .btn-primary.focus,
|
|
||||||
.btn-primary.disabled:active,
|
|
||||||
.btn-primary[disabled]:active,
|
|
||||||
fieldset[disabled] .btn-primary:active,
|
|
||||||
.btn-primary.disabled.active,
|
|
||||||
.btn-primary[disabled].active,
|
|
||||||
fieldset[disabled] .btn-primary.active {
|
|
||||||
background-color: #265a88;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-success {
|
|
||||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
|
|
||||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #3e8f3e;
|
|
||||||
}
|
|
||||||
.btn-success:hover,
|
|
||||||
.btn-success:focus {
|
|
||||||
background-color: #419641;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-success:active,
|
|
||||||
.btn-success.active {
|
|
||||||
background-color: #419641;
|
|
||||||
border-color: #3e8f3e;
|
|
||||||
}
|
|
||||||
.btn-success.disabled,
|
|
||||||
.btn-success[disabled],
|
|
||||||
fieldset[disabled] .btn-success,
|
|
||||||
.btn-success.disabled:hover,
|
|
||||||
.btn-success[disabled]:hover,
|
|
||||||
fieldset[disabled] .btn-success:hover,
|
|
||||||
.btn-success.disabled:focus,
|
|
||||||
.btn-success[disabled]:focus,
|
|
||||||
fieldset[disabled] .btn-success:focus,
|
|
||||||
.btn-success.disabled.focus,
|
|
||||||
.btn-success[disabled].focus,
|
|
||||||
fieldset[disabled] .btn-success.focus,
|
|
||||||
.btn-success.disabled:active,
|
|
||||||
.btn-success[disabled]:active,
|
|
||||||
fieldset[disabled] .btn-success:active,
|
|
||||||
.btn-success.disabled.active,
|
|
||||||
.btn-success[disabled].active,
|
|
||||||
fieldset[disabled] .btn-success.active {
|
|
||||||
background-color: #419641;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-info {
|
|
||||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
|
|
||||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #28a4c9;
|
|
||||||
}
|
|
||||||
.btn-info:hover,
|
|
||||||
.btn-info:focus {
|
|
||||||
background-color: #2aabd2;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-info:active,
|
|
||||||
.btn-info.active {
|
|
||||||
background-color: #2aabd2;
|
|
||||||
border-color: #28a4c9;
|
|
||||||
}
|
|
||||||
.btn-info.disabled,
|
|
||||||
.btn-info[disabled],
|
|
||||||
fieldset[disabled] .btn-info,
|
|
||||||
.btn-info.disabled:hover,
|
|
||||||
.btn-info[disabled]:hover,
|
|
||||||
fieldset[disabled] .btn-info:hover,
|
|
||||||
.btn-info.disabled:focus,
|
|
||||||
.btn-info[disabled]:focus,
|
|
||||||
fieldset[disabled] .btn-info:focus,
|
|
||||||
.btn-info.disabled.focus,
|
|
||||||
.btn-info[disabled].focus,
|
|
||||||
fieldset[disabled] .btn-info.focus,
|
|
||||||
.btn-info.disabled:active,
|
|
||||||
.btn-info[disabled]:active,
|
|
||||||
fieldset[disabled] .btn-info:active,
|
|
||||||
.btn-info.disabled.active,
|
|
||||||
.btn-info[disabled].active,
|
|
||||||
fieldset[disabled] .btn-info.active {
|
|
||||||
background-color: #2aabd2;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-warning {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
|
|
||||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #e38d13;
|
|
||||||
}
|
|
||||||
.btn-warning:hover,
|
|
||||||
.btn-warning:focus {
|
|
||||||
background-color: #eb9316;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-warning:active,
|
|
||||||
.btn-warning.active {
|
|
||||||
background-color: #eb9316;
|
|
||||||
border-color: #e38d13;
|
|
||||||
}
|
|
||||||
.btn-warning.disabled,
|
|
||||||
.btn-warning[disabled],
|
|
||||||
fieldset[disabled] .btn-warning,
|
|
||||||
.btn-warning.disabled:hover,
|
|
||||||
.btn-warning[disabled]:hover,
|
|
||||||
fieldset[disabled] .btn-warning:hover,
|
|
||||||
.btn-warning.disabled:focus,
|
|
||||||
.btn-warning[disabled]:focus,
|
|
||||||
fieldset[disabled] .btn-warning:focus,
|
|
||||||
.btn-warning.disabled.focus,
|
|
||||||
.btn-warning[disabled].focus,
|
|
||||||
fieldset[disabled] .btn-warning.focus,
|
|
||||||
.btn-warning.disabled:active,
|
|
||||||
.btn-warning[disabled]:active,
|
|
||||||
fieldset[disabled] .btn-warning:active,
|
|
||||||
.btn-warning.disabled.active,
|
|
||||||
.btn-warning[disabled].active,
|
|
||||||
fieldset[disabled] .btn-warning.active {
|
|
||||||
background-color: #eb9316;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-danger {
|
|
||||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
|
|
||||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #b92c28;
|
|
||||||
}
|
|
||||||
.btn-danger:hover,
|
|
||||||
.btn-danger:focus {
|
|
||||||
background-color: #c12e2a;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-danger:active,
|
|
||||||
.btn-danger.active {
|
|
||||||
background-color: #c12e2a;
|
|
||||||
border-color: #b92c28;
|
|
||||||
}
|
|
||||||
.btn-danger.disabled,
|
|
||||||
.btn-danger[disabled],
|
|
||||||
fieldset[disabled] .btn-danger,
|
|
||||||
.btn-danger.disabled:hover,
|
|
||||||
.btn-danger[disabled]:hover,
|
|
||||||
fieldset[disabled] .btn-danger:hover,
|
|
||||||
.btn-danger.disabled:focus,
|
|
||||||
.btn-danger[disabled]:focus,
|
|
||||||
fieldset[disabled] .btn-danger:focus,
|
|
||||||
.btn-danger.disabled.focus,
|
|
||||||
.btn-danger[disabled].focus,
|
|
||||||
fieldset[disabled] .btn-danger.focus,
|
|
||||||
.btn-danger.disabled:active,
|
|
||||||
.btn-danger[disabled]:active,
|
|
||||||
fieldset[disabled] .btn-danger:active,
|
|
||||||
.btn-danger.disabled.active,
|
|
||||||
.btn-danger[disabled].active,
|
|
||||||
fieldset[disabled] .btn-danger.active {
|
|
||||||
background-color: #c12e2a;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.thumbnail,
|
|
||||||
.img-thumbnail {
|
|
||||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.dropdown-menu > li > a:hover,
|
|
||||||
.dropdown-menu > li > a:focus {
|
|
||||||
background-color: #e8e8e8;
|
|
||||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
|
||||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.dropdown-menu > .active > a,
|
|
||||||
.dropdown-menu > .active > a:hover,
|
|
||||||
.dropdown-menu > .active > a:focus {
|
|
||||||
background-color: #2e6da4;
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.navbar-default {
|
|
||||||
background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8));
|
|
||||||
background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-radius: 4px;
|
|
||||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.navbar-default .navbar-nav > .open > a,
|
|
||||||
.navbar-default .navbar-nav > .active > a {
|
|
||||||
background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
|
|
||||||
background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.navbar-brand,
|
|
||||||
.navbar-nav > li > a {
|
|
||||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
|
|
||||||
}
|
|
||||||
.navbar-inverse {
|
|
||||||
background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
|
|
||||||
background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.navbar-inverse .navbar-nav > .open > a,
|
|
||||||
.navbar-inverse .navbar-nav > .active > a {
|
|
||||||
background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
|
|
||||||
background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
|
||||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
|
||||||
}
|
|
||||||
.navbar-inverse .navbar-brand,
|
|
||||||
.navbar-inverse .navbar-nav > li > a {
|
|
||||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
|
|
||||||
}
|
|
||||||
.navbar-static-top,
|
|
||||||
.navbar-fixed-top,
|
|
||||||
.navbar-fixed-bottom {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.navbar .navbar-nav .open .dropdown-menu > .active > a,
|
|
||||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
|
|
||||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
|
|
||||||
color: #fff;
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert {
|
|
||||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
|
|
||||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
|
||||||
}
|
|
||||||
.alert-success {
|
|
||||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
|
|
||||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #b2dba1;
|
|
||||||
}
|
|
||||||
.alert-info {
|
|
||||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
|
|
||||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #9acfea;
|
|
||||||
}
|
|
||||||
.alert-warning {
|
|
||||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
|
|
||||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #f5e79e;
|
|
||||||
}
|
|
||||||
.alert-danger {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
|
|
||||||
background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #dca7a7;
|
|
||||||
}
|
|
||||||
.progress {
|
|
||||||
background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
|
|
||||||
background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar {
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-success {
|
|
||||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
|
|
||||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-info {
|
|
||||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
|
|
||||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-warning {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
|
|
||||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-danger {
|
|
||||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
|
|
||||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-striped {
|
|
||||||
background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
|
||||||
background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
|
||||||
background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
|
||||||
}
|
|
||||||
.list-group {
|
|
||||||
border-radius: 4px;
|
|
||||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.list-group-item.active,
|
|
||||||
.list-group-item.active:hover,
|
|
||||||
.list-group-item.active:focus {
|
|
||||||
text-shadow: 0 -1px 0 #286090;
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #2b669a;
|
|
||||||
}
|
|
||||||
.list-group-item.active .badge,
|
|
||||||
.list-group-item.active:hover .badge,
|
|
||||||
.list-group-item.active:focus .badge {
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
|
||||||
}
|
|
||||||
.panel-default > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
|
||||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-primary > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-success > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6));
|
|
||||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-info > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3));
|
|
||||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-warning > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc));
|
|
||||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-danger > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc));
|
|
||||||
background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.well {
|
|
||||||
background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5));
|
|
||||||
background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #dcdcdc;
|
|
||||||
-webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
|
||||||
}
|
|
||||||
/*# sourceMappingURL=bootstrap-theme.css.map */
|
|
File diff suppressed because one or more lines are too long
6
web/static/css/bootstrap-theme.min.css
vendored
6
web/static/css/bootstrap-theme.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6757
web/static/css/bootstrap.css
vendored
6757
web/static/css/bootstrap.css
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
11
web/static/css/bootstrap.min.css
vendored
11
web/static/css/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,3 +1,7 @@
|
|||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
#head {
|
#head {
|
||||||
height:160px;
|
height:160px;
|
||||||
line-height: 160px;
|
line-height: 160px;
|
||||||
@ -33,7 +37,7 @@
|
|||||||
font-size: 70.7%;
|
font-size: 70.7%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
#wrapper {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 300px auto;
|
grid-template-columns: 300px auto;
|
||||||
}
|
}
|
||||||
@ -61,6 +65,13 @@ body {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Highlights */
|
||||||
.negative {
|
.negative {
|
||||||
color: #d50000;
|
color: #d50000;
|
||||||
|
}
|
||||||
|
.zero {
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
.future {
|
||||||
|
background-color: #cccccc;
|
||||||
}
|
}
|
2377
web/static/js/bootstrap.js
vendored
2377
web/static/js/bootstrap.js
vendored
File diff suppressed because it is too large
Load Diff
12
web/static/js/bootstrap.min.js
vendored
12
web/static/js/bootstrap.min.js
vendored
File diff suppressed because one or more lines are too long
1
web/static/js/bootstrap.min.js.map
Normal file
1
web/static/js/bootstrap.min.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -14,15 +14,25 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">New Transaction</h5>
|
<h5 class="modal-title">New Transaction</h5>
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="newtransactionform" action="/api/v1/transaction/new" method="POST">
|
<form id="newtransactionform" action="/api/v1/transaction/new" method="POST">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="account_id" value="{{.Account.ID}}" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="category_id">Category</label>
|
||||||
|
<select name="category_id" class="form-control">
|
||||||
|
<option value="">-- none --</option>
|
||||||
|
{{range .Categories}}
|
||||||
|
<option value="{{.ID}}">{{.Group}} : {{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="date">Date</label>
|
<label for="date">Date</label>
|
||||||
<input type="date" name="date" class="form-control" value="{{.Now}}" />
|
<input type="date" name="date" class="form-control" value="{{now.Format "2006-01-02"}}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="memo">Memo</label>
|
<label for="memo">Memo</label>
|
||||||
@ -34,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
<input type="submit" class="btn btn-primary" value="Create" class="form-control" />
|
<input type="submit" class="btn btn-primary" value="Create" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
64
web/transaction.html
Normal file
64
web/transaction.html
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}{{.Account.Name}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<div>
|
||||||
|
<script>
|
||||||
|
$(document).ready(function () {
|
||||||
|
$('#errorcreatingtransaction').hide();
|
||||||
|
$('#newtransactionform').ajaxForm({
|
||||||
|
error: function() {
|
||||||
|
$('#errorcreatingtransaction').show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edit Transaction</h5>
|
||||||
|
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="newtransactionform" action="/api/v1/transaction/{{.Transaction.ID}}" method="POST">
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="account_id" value="{{.Account.ID}}" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="category_id">Category</label>
|
||||||
|
<select name="category_id" class="form-control">
|
||||||
|
<option value="" {{if not $.Transaction.CategoryID.Valid}}selected{{end}}>-- none --</option>
|
||||||
|
{{range .Categories}}
|
||||||
|
<option value="{{.ID}}" {{if and $.Transaction.CategoryID.Valid (eq .ID $.Transaction.CategoryID.UUID)}}selected{{end}}>{{.Group}} : {{.Name}}</option>
|
||||||
|
{{- end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="payee_id">Payee</label>
|
||||||
|
<select name="payee_id" class="form-control">
|
||||||
|
<option value="" {{if not $.Transaction.PayeeID.Valid}}selected{{end}}>-- none --</option>
|
||||||
|
{{range .Payees}}
|
||||||
|
<option value="{{.ID}}" {{if and $.Transaction.PayeeID.Valid (eq .ID $.Transaction.PayeeID.UUID)}}selected{{end}}>{{.Name}}</option>
|
||||||
|
{{- end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="date">Date</label>
|
||||||
|
<input type="date" name="date" class="form-control" value="{{.Transaction.Date.Format "2006-01-02"}}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="memo">Memo</label>
|
||||||
|
<input type="text" name="memo" class="form-control" value="{{.Transaction.Memo}}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amount">Amount</label>
|
||||||
|
<input type="number" name="amount" class="form-control" placeholder="0.00" value="{{printf "%.2f" .Transaction.Amount.GetFloat64}}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<input type="submit" class="btn btn-primary" name="create" value="Create" class="form-control" />
|
||||||
|
<input type="submit" class="btn btn-danger" name="delete" value="Delete" class="form-control" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
Reference in New Issue
Block a user