Compare commits
	
		
			71 Commits
		
	
	
		
			v0.1.0
			...
			6fe30231d8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe30231d8 | |||
| 49af9cd2ef | |||
| ac27dc783e | |||
| 3c17d674f9 | |||
| 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",
 | 
			
		||||
        "tasks": [
 | 
			
		||||
                {
 | 
			
		||||
                        "label": "earthly +run",
 | 
			
		||||
                        "label": "task watch +run",
 | 
			
		||||
                        "type": "shell",
 | 
			
		||||
                        "command": "earthly +run",
 | 
			
		||||
                        "command": "task -w run",
 | 
			
		||||
                        "problemMatcher": [],
 | 
			
		||||
                        "group": {
 | 
			
		||||
                                "kind": "build",
 | 
			
		||||
                                "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() {
 | 
			
		||||
	cfg, err := config.LoadConfig()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalf("Could not load Config: %v", err)
 | 
			
		||||
		log.Fatalf("Could not load config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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 {
 | 
			
		||||
		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{}
 | 
			
		||||
 | 
			
		||||
	h := &http.Handler{
 | 
			
		||||
		Service:             us,
 | 
			
		||||
		Service:             q,
 | 
			
		||||
		TokenVerifier:       tv,
 | 
			
		||||
		CredentialsVerifier: bv,
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								go.mod
									
									
									
									
									
								
							@@ -5,7 +5,6 @@ go 1.17
 | 
			
		||||
require (
 | 
			
		||||
	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 | 
			
		||||
	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/jackc/pgx/v4 v4.13.0
 | 
			
		||||
	github.com/pressly/goose/v3 v3.3.1
 | 
			
		||||
@@ -24,7 +23,7 @@ require (
 | 
			
		||||
	github.com/jackc/pgpassfile v1.0.0 // indirect
 | 
			
		||||
	github.com/jackc/pgproto3/v2 v2.1.1 // 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/leodido/go-urn v1.2.0 // 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 (
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type AccountsData struct {
 | 
			
		||||
@@ -19,39 +17,3 @@ func (h *Handler) accounts(c *gin.Context) {
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
	"github.com/pressly/goose/v3"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -19,13 +21,97 @@ func (h *Handler) admin(c *gin.Context) {
 | 
			
		||||
func (h *Handler) clearDatabase(c *gin.Context) {
 | 
			
		||||
	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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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.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
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
			
		||||
@@ -10,8 +9,10 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type AlwaysNeededData struct {
 | 
			
		||||
	Budget   postgres.Budget
 | 
			
		||||
	Accounts []postgres.GetAccountsWithBalanceRow
 | 
			
		||||
	Budget            postgres.Budget
 | 
			
		||||
	Accounts          []postgres.GetAccountsWithBalanceRow
 | 
			
		||||
	OnBudgetAccounts  []postgres.GetAccountsWithBalanceRow
 | 
			
		||||
	OffBudgetAccounts []postgres.GetAccountsWithBalanceRow
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *Handler) getImportantData(c *gin.Context) {
 | 
			
		||||
@@ -23,21 +24,32 @@ func (h *Handler) getImportantData(c *gin.Context) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	budget, err := h.Service.DB.GetBudget(context.Background(), budgetUUID)
 | 
			
		||||
	budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.AbortWithError(http.StatusNotFound, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	accounts, err := h.Service.DB.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
 | 
			
		||||
	accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var onBudgetAccounts, offBudgetAccounts []postgres.GetAccountsWithBalanceRow
 | 
			
		||||
	for _, account := range accounts {
 | 
			
		||||
		if account.OnBudget {
 | 
			
		||||
			onBudgetAccounts = append(onBudgetAccounts, account)
 | 
			
		||||
		} else {
 | 
			
		||||
			offBudgetAccounts = append(offBudgetAccounts, account)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	base := AlwaysNeededData{
 | 
			
		||||
		Accounts: accounts,
 | 
			
		||||
		Budget:   budget,
 | 
			
		||||
		Accounts:          accounts,
 | 
			
		||||
		OnBudgetAccounts:  onBudgetAccounts,
 | 
			
		||||
		OffBudgetAccounts: offBudgetAccounts,
 | 
			
		||||
		Budget:            budget,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.Set("data", base)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +1,22 @@
 | 
			
		||||
package http
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"git.javil.eu/jacob1123/budgeteer"
 | 
			
		||||
	"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type BudgetData struct {
 | 
			
		||||
type AllAccountsData struct {
 | 
			
		||||
	AlwaysNeededData
 | 
			
		||||
	Account      *postgres.Account
 | 
			
		||||
	Categories   []postgres.GetCategoriesRow
 | 
			
		||||
	Transactions []postgres.GetTransactionsForBudgetRow
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *Handler) budget(c *gin.Context) {
 | 
			
		||||
func (h *Handler) allAccounts(c *gin.Context) {
 | 
			
		||||
	budgetID := c.Param("budgetid")
 | 
			
		||||
	budgetUUID, err := uuid.Parse(budgetID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -22,16 +24,41 @@ func (h *Handler) budget(c *gin.Context) {
 | 
			
		||||
		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 {
 | 
			
		||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	d := BudgetData{
 | 
			
		||||
	d := AllAccountsData{
 | 
			
		||||
		c.MustGet("data").(AlwaysNeededData),
 | 
			
		||||
		&postgres.Account{
 | 
			
		||||
			Name: "All accounts",
 | 
			
		||||
		},
 | 
			
		||||
		categories,
 | 
			
		||||
		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
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strconv"
 | 
			
		||||
@@ -9,95 +8,175 @@ import (
 | 
			
		||||
 | 
			
		||||
	"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type BudgetingData struct {
 | 
			
		||||
	AlwaysNeededData
 | 
			
		||||
	Categories []postgres.GetCategoriesWithBalanceRow
 | 
			
		||||
	Date       time.Time
 | 
			
		||||
	Next       time.Time
 | 
			
		||||
	Previous   time.Time
 | 
			
		||||
	Categories       []CategoryWithBalance
 | 
			
		||||
	AvailableBalance float64
 | 
			
		||||
	Date             time.Time
 | 
			
		||||
	Next             time.Time
 | 
			
		||||
	Previous         time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *Handler) budgeting(c *gin.Context) {
 | 
			
		||||
	budgetID := c.Param("budgetid")
 | 
			
		||||
	budgetUUID, err := uuid.Parse(budgetID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.Redirect(http.StatusTemporaryRedirect, "/login")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
func getFirstOfMonth(year, month int, location *time.Location) time.Time {
 | 
			
		||||
	return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
	yearString := c.Param("year")
 | 
			
		||||
	monthString := c.Param("month")
 | 
			
		||||
	if yearString != "" && monthString != "" {
 | 
			
		||||
		year, err = strconv.Atoi(yearString)
 | 
			
		||||
		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)
 | 
			
		||||
	if yearString == "" && monthString == "" {
 | 
			
		||||
		return getFirstOfMonthTime(time.Now()), nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, now.Location())
 | 
			
		||||
	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)
 | 
			
		||||
	year, err := strconv.Atoi(yearString)
 | 
			
		||||
	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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
 | 
			
		||||
	firstOfPreviousMonth := firstOfMonth.AddDate(0, -1, 0)
 | 
			
		||||
	d := BudgetingData{
 | 
			
		||||
		c.MustGet("data").(AlwaysNeededData),
 | 
			
		||||
		categories,
 | 
			
		||||
		firstOfMonth,
 | 
			
		||||
		firstOfNextMonth,
 | 
			
		||||
		firstOfPreviousMonth,
 | 
			
		||||
		AlwaysNeededData: alwaysNeededData,
 | 
			
		||||
		Date:             firstOfMonth,
 | 
			
		||||
		Next:             firstOfNextMonth,
 | 
			
		||||
		Previous:         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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
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) {
 | 
			
		||||
	categoriesWithBalance := []CategoryWithBalance{}
 | 
			
		||||
	hiddenCategory := CategoryWithBalance{
 | 
			
		||||
		GetCategoriesRow: &postgres.GetCategoriesRow{
 | 
			
		||||
			Name:  "",
 | 
			
		||||
			Group: "Hidden Categories",
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rows, err := h.Service.DB.DeleteAllAssignments(c.Request.Context(), budgetUUID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
			
		||||
		return
 | 
			
		||||
	var moneyUsed float64 = 0
 | 
			
		||||
	for i := range categories {
 | 
			
		||||
		cat := &categories[i]
 | 
			
		||||
		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)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Printf("Deleted %d transactions\n", rows)
 | 
			
		||||
	return categoriesWithBalance, moneyUsed, nil
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ import (
 | 
			
		||||
 | 
			
		||||
func (h *Handler) dashboard(c *gin.Context) {
 | 
			
		||||
	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 {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										132
									
								
								http/http.go
									
									
									
									
									
								
							
							
						
						
									
										132
									
								
								http/http.go
									
									
									
									
									
								
							@@ -1,9 +1,9 @@
 | 
			
		||||
package http
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io/fs"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.javil.eu/jacob1123/budgeteer"
 | 
			
		||||
@@ -12,12 +12,11 @@ import (
 | 
			
		||||
	"git.javil.eu/jacob1123/budgeteer/web"
 | 
			
		||||
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Handler handles incoming requests
 | 
			
		||||
type Handler struct {
 | 
			
		||||
	Service             *postgres.Repository
 | 
			
		||||
	Service             *postgres.Database
 | 
			
		||||
	TokenVerifier       budgeteer.TokenVerifier
 | 
			
		||||
	CredentialsVerifier *bcrypt.Verifier
 | 
			
		||||
}
 | 
			
		||||
@@ -30,6 +29,7 @@ const (
 | 
			
		||||
// Serve starts the HTTP Server
 | 
			
		||||
func (h *Handler) Serve() {
 | 
			
		||||
	router := gin.Default()
 | 
			
		||||
	router.FuncMap["now"] = time.Now
 | 
			
		||||
 | 
			
		||||
	templates, err := NewTemplates(router.FuncMap)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -42,6 +42,7 @@ func (h *Handler) Serve() {
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic("couldn't open static files")
 | 
			
		||||
	}
 | 
			
		||||
	router.Use(enableCachingForStaticFiles())
 | 
			
		||||
	router.StaticFS("/static", http.FS(static))
 | 
			
		||||
 | 
			
		||||
	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.getImportantData)
 | 
			
		||||
	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/all-accounts", h.budget)
 | 
			
		||||
	withBudget.GET("/budget/:budgetid/all-accounts", h.allAccounts)
 | 
			
		||||
	withBudget.GET("/budget/:budgetid/accounts", h.accounts)
 | 
			
		||||
	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")
 | 
			
		||||
 | 
			
		||||
@@ -82,122 +86,16 @@ func (h *Handler) Serve() {
 | 
			
		||||
 | 
			
		||||
	transaction := authenticated.Group("/transaction")
 | 
			
		||||
	transaction.POST("/new", h.newTransaction)
 | 
			
		||||
	transaction.POST("/:transactionid", h.newTransaction)
 | 
			
		||||
	transaction.POST("/import/ynab", h.importYNAB)
 | 
			
		||||
 | 
			
		||||
	router.Run(":1323")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *Handler) importYNAB(c *gin.Context) {
 | 
			
		||||
	budgetID, succ := c.GetPostForm("budget_id")
 | 
			
		||||
	if !succ {
 | 
			
		||||
		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
 | 
			
		||||
		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
 | 
			
		||||
func enableCachingForStaticFiles() gin.HandlerFunc {
 | 
			
		||||
	return func(c *gin.Context) {
 | 
			
		||||
		if strings.HasPrefix(c.Request.RequestURI, "/static/") {
 | 
			
		||||
			c.Header("Cache-Control", "max-age=86400")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -68,7 +68,7 @@ func (h *Handler) loginPost(c *gin.Context) {
 | 
			
		||||
	username, _ := c.GetPostForm("username")
 | 
			
		||||
	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 {
 | 
			
		||||
		c.AbortWithError(http.StatusUnauthorized, err)
 | 
			
		||||
		return
 | 
			
		||||
@@ -84,7 +84,8 @@ func (h *Handler) loginPost(c *gin.Context) {
 | 
			
		||||
		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())
 | 
			
		||||
	c.SetCookie(authCookie, t, maxAge, "", "", false, true)
 | 
			
		||||
	c.JSON(http.StatusOK, map[string]string{
 | 
			
		||||
@@ -97,7 +98,7 @@ func (h *Handler) registerPost(c *gin.Context) {
 | 
			
		||||
	password, _ := c.GetPostForm("password")
 | 
			
		||||
	name, _ := c.GetPostForm("name")
 | 
			
		||||
 | 
			
		||||
	_, err := h.Service.DB.GetUserByUsername(context.Background(), email)
 | 
			
		||||
	_, err := h.Service.GetUserByUsername(c.Request.Context(), email)
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		c.AbortWithStatus(http.StatusUnauthorized)
 | 
			
		||||
		return
 | 
			
		||||
@@ -114,7 +115,7 @@ func (h *Handler) registerPost(c *gin.Context) {
 | 
			
		||||
		Password: hash,
 | 
			
		||||
		Email:    email,
 | 
			
		||||
	}
 | 
			
		||||
	_, err = h.Service.DB.CreateUser(context.Background(), createUser)
 | 
			
		||||
	_, err = h.Service.CreateUser(c.Request.Context(), createUser)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@ type Templates struct {
 | 
			
		||||
func NewTemplates(funcMap template.FuncMap) (*Templates, error) {
 | 
			
		||||
	templates, err := fs.Glob(web.Templates, "*.tpl")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
		return nil, fmt.Errorf("glob: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/csv"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
	"unicode/utf8"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type YNABImport struct {
 | 
			
		||||
	Context        context.Context
 | 
			
		||||
	accounts       []postgres.Account
 | 
			
		||||
	payees         []postgres.Payee
 | 
			
		||||
	categories     []postgres.GetCategoriesRow
 | 
			
		||||
	categoryGroups []postgres.CategoryGroup
 | 
			
		||||
	queries        *postgres.Queries
 | 
			
		||||
	budgetID       uuid.UUID
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewYNABImport(q *postgres.Queries, budgetID uuid.UUID) (*YNABImport, error) {
 | 
			
		||||
	accounts, err := q.GetAccounts(context.Background(), budgetID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	payees, err := q.GetPayees(context.Background(), budgetID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	categories, err := q.GetCategories(context.Background(), budgetID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	categoryGroups, err := q.GetCategoryGroups(context.Background(), budgetID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &YNABImport{
 | 
			
		||||
		Context:        context.Background(),
 | 
			
		||||
		accounts:       accounts,
 | 
			
		||||
		payees:         payees,
 | 
			
		||||
		categories:     categories,
 | 
			
		||||
		categoryGroups: categoryGroups,
 | 
			
		||||
		queries:        q,
 | 
			
		||||
		budgetID:       budgetID,
 | 
			
		||||
	}, nil
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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:] {
 | 
			
		||||
		//"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
 | 
			
		||||
func (h *Handler) importYNAB(c *gin.Context) {
 | 
			
		||||
	budgetID, succ := c.GetPostForm("budget_id")
 | 
			
		||||
	if !succ {
 | 
			
		||||
		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	budgetUUID, err := uuid.Parse(budgetID)
 | 
			
		||||
	if !succ {
 | 
			
		||||
		c.AbortWithError(http.StatusBadRequest, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ynab, err := postgres.NewYNABImport(c.Request.Context(), h.Service.Queries, 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
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ const createAccount = `-- name: CreateAccount :one
 | 
			
		||||
INSERT INTO accounts
 | 
			
		||||
(name, budget_id)
 | 
			
		||||
VALUES ($1, $2)
 | 
			
		||||
RETURNING id, budget_id, name
 | 
			
		||||
RETURNING id, budget_id, name, on_budget
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
type CreateAccountParams struct {
 | 
			
		||||
@@ -24,24 +24,34 @@ type CreateAccountParams struct {
 | 
			
		||||
func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) {
 | 
			
		||||
	row := q.db.QueryRowContext(ctx, createAccount, arg.Name, arg.BudgetID)
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error) {
 | 
			
		||||
	row := q.db.QueryRowContext(ctx, getAccount, id)
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
ORDER BY accounts.name
 | 
			
		||||
`
 | 
			
		||||
@@ -55,7 +65,12 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
 | 
			
		||||
	var items []Account
 | 
			
		||||
	for rows.Next() {
 | 
			
		||||
		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
 | 
			
		||||
		}
 | 
			
		||||
		items = append(items, i)
 | 
			
		||||
@@ -70,7 +85,7 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
LEFT JOIN transactions ON transactions.account_id = accounts.id
 | 
			
		||||
WHERE accounts.budget_id = $1
 | 
			
		||||
@@ -80,9 +95,10 @@ ORDER BY accounts.name
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
type GetAccountsWithBalanceRow struct {
 | 
			
		||||
	ID      uuid.UUID
 | 
			
		||||
	Name    string
 | 
			
		||||
	Balance Numeric
 | 
			
		||||
	ID       uuid.UUID
 | 
			
		||||
	Name     string
 | 
			
		||||
	OnBudget bool
 | 
			
		||||
	Balance  Numeric
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
	for rows.Next() {
 | 
			
		||||
		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
 | 
			
		||||
		}
 | 
			
		||||
		items = append(items, i)
 | 
			
		||||
 
 | 
			
		||||
@@ -52,3 +52,37 @@ func (q *Queries) DeleteAllAssignments(ctx context.Context, budgetID uuid.UUID)
 | 
			
		||||
	}
 | 
			
		||||
	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 (
 | 
			
		||||
	"context"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const createBudget = `-- name: CreateBudget :one
 | 
			
		||||
INSERT INTO budgets
 | 
			
		||||
(name, last_modification)
 | 
			
		||||
VALUES ($1, NOW())
 | 
			
		||||
RETURNING id, name, last_modification
 | 
			
		||||
(name, income_category_id, last_modification)
 | 
			
		||||
VALUES ($1, $2, NOW())
 | 
			
		||||
RETURNING id, name, last_modification, income_category_id
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
func (q *Queries) CreateBudget(ctx context.Context, name string) (Budget, error) {
 | 
			
		||||
	row := q.db.QueryRowContext(ctx, createBudget, name)
 | 
			
		||||
type CreateBudgetParams struct {
 | 
			
		||||
	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
 | 
			
		||||
	err := row.Scan(&i.ID, &i.Name, &i.LastModification)
 | 
			
		||||
	err := row.Scan(
 | 
			
		||||
		&i.ID,
 | 
			
		||||
		&i.Name,
 | 
			
		||||
		&i.LastModification,
 | 
			
		||||
		&i.IncomeCategoryID,
 | 
			
		||||
	)
 | 
			
		||||
	return i, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
func (q *Queries) GetBudget(ctx context.Context, id uuid.UUID) (Budget, error) {
 | 
			
		||||
	row := q.db.QueryRowContext(ctx, getBudget, id)
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
WHERE user_budgets.user_id = $1
 | 
			
		||||
`
 | 
			
		||||
@@ -50,7 +66,12 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
 | 
			
		||||
	var items []Budget
 | 
			
		||||
	for rows.Next() {
 | 
			
		||||
		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
 | 
			
		||||
		}
 | 
			
		||||
		items = append(items, i)
 | 
			
		||||
@@ -63,3 +84,42 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
 | 
			
		||||
	}
 | 
			
		||||
	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 (
 | 
			
		||||
	"context"
 | 
			
		||||
	"database/sql"
 | 
			
		||||
	"fmt"
 | 
			
		||||
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Budget returns a budget for a given id.
 | 
			
		||||
func (s *Repository) Budget(id uuid.UUID) (*Budget, error) {
 | 
			
		||||
	budget, err := s.DB.GetBudget(context.Background(), id)
 | 
			
		||||
// NewBudget creates a budget and adds it to the current user
 | 
			
		||||
func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) {
 | 
			
		||||
	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 {
 | 
			
		||||
		return nil, 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
 | 
			
		||||
		return nil, fmt.Errorf("create budget: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
 | 
			
		||||
	_, err = s.DB.LinkBudgetToUser(context.Background(), ub)
 | 
			
		||||
	_, err = q.LinkBudgetToUser(context, ub)
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,6 @@ package postgres
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"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
 | 
			
		||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
			
		||||
WHERE category_groups.budget_id = $1
 | 
			
		||||
ORDER BY category_groups.name, categories.name
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
type GetCategoriesRow struct {
 | 
			
		||||
@@ -89,81 +89,6 @@ func (q *Queries) GetCategories(ctx context.Context, budgetID uuid.UUID) ([]GetC
 | 
			
		||||
	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
 | 
			
		||||
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups 
 | 
			
		||||
WHERE category_groups.budget_id = $1
 | 
			
		||||
 
 | 
			
		||||
@@ -12,18 +12,26 @@ import (
 | 
			
		||||
//go:embed schema/*.sql
 | 
			
		||||
var migrations embed.FS
 | 
			
		||||
 | 
			
		||||
type Database struct {
 | 
			
		||||
	*Queries
 | 
			
		||||
	*sql.DB
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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)
 | 
			
		||||
	conn, err := sql.Open("pgx", connString)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, nil, err
 | 
			
		||||
		return nil, fmt.Errorf("open connection: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	goose.SetBaseFS(migrations)
 | 
			
		||||
	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
 | 
			
		||||
	BudgetID uuid.UUID
 | 
			
		||||
	Name     string
 | 
			
		||||
	OnBudget bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Assignment struct {
 | 
			
		||||
@@ -23,10 +24,18 @@ type Assignment struct {
 | 
			
		||||
	Amount     Numeric
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AssignmentsByMonth struct {
 | 
			
		||||
	Date       time.Time
 | 
			
		||||
	CategoryID uuid.UUID
 | 
			
		||||
	BudgetID   uuid.UUID
 | 
			
		||||
	Amount     int64
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Budget struct {
 | 
			
		||||
	ID               uuid.UUID
 | 
			
		||||
	Name             string
 | 
			
		||||
	LastModification sql.NullTime
 | 
			
		||||
	IncomeCategoryID uuid.UUID
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Category struct {
 | 
			
		||||
@@ -55,6 +64,14 @@ type Transaction struct {
 | 
			
		||||
	AccountID  uuid.UUID
 | 
			
		||||
	CategoryID 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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,9 @@ type Numeric struct {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (n Numeric) GetFloat64() float64 {
 | 
			
		||||
	if n.Status != pgtype.Present {
 | 
			
		||||
		return 0
 | 
			
		||||
	}
 | 
			
		||||
	var balance float64
 | 
			
		||||
	err := n.AssignTo(&balance)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -15,7 +18,18 @@ func (n Numeric) GetFloat64() float64 {
 | 
			
		||||
	return balance
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (n Numeric) GetPositive() bool {
 | 
			
		||||
func (n Numeric) IsPositive() bool {
 | 
			
		||||
	if n.Status != pgtype.Present {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
	float := n.GetFloat64()
 | 
			
		||||
	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
 | 
			
		||||
SELECT payees.id, payees.budget_id, payees.name FROM payees 
 | 
			
		||||
WHERE payees.budget_id = $1
 | 
			
		||||
ORDER BY name
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
-- 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
 | 
			
		||||
LEFT JOIN transactions ON transactions.account_id = accounts.id
 | 
			
		||||
WHERE accounts.budget_id = $1
 | 
			
		||||
 
 | 
			
		||||
@@ -11,3 +11,8 @@ DELETE FROM assignments
 | 
			
		||||
USING categories
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
-- name: GetAssignmentsByMonthAndCategory :many
 | 
			
		||||
SELECT *
 | 
			
		||||
FROM assignments_by_month
 | 
			
		||||
WHERE assignments_by_month.budget_id = @budget_id;
 | 
			
		||||
@@ -1,9 +1,14 @@
 | 
			
		||||
-- name: CreateBudget :one
 | 
			
		||||
INSERT INTO budgets
 | 
			
		||||
(name, last_modification)
 | 
			
		||||
VALUES ($1, NOW())
 | 
			
		||||
(name, income_category_id, last_modification)
 | 
			
		||||
VALUES ($1, $2, NOW())
 | 
			
		||||
RETURNING *;
 | 
			
		||||
 | 
			
		||||
-- name: SetInflowCategory :exec
 | 
			
		||||
UPDATE budgets
 | 
			
		||||
        SET income_category_id = $1
 | 
			
		||||
        WHERE budgets.id = $2;
 | 
			
		||||
 | 
			
		||||
-- name: GetBudgetsForUser :many
 | 
			
		||||
SELECT budgets.* FROM budgets 
 | 
			
		||||
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
 | 
			
		||||
@@ -12,3 +17,18 @@ WHERE user_budgets.user_id = $1;
 | 
			
		||||
-- name: GetBudget :one
 | 
			
		||||
SELECT * FROM budgets 
 | 
			
		||||
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
 | 
			
		||||
SELECT categories.*, category_groups.name as group FROM categories
 | 
			
		||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
			
		||||
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
 | 
			
		||||
WHERE category_groups.budget_id = $1
 | 
			
		||||
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
 | 
			
		||||
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
 | 
			
		||||
INSERT INTO transactions
 | 
			
		||||
(date, memo, amount, account_id, payee_id, category_id)
 | 
			
		||||
VALUES ($1, $2, $3, $4, $5, $6)
 | 
			
		||||
(date, memo, amount, account_id, payee_id, category_id, group_id)
 | 
			
		||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
 | 
			
		||||
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
 | 
			
		||||
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
 | 
			
		||||
FROM transactions 
 | 
			
		||||
INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
			
		||||
@@ -17,7 +35,7 @@ ORDER BY transactions.date DESC
 | 
			
		||||
LIMIT 200;
 | 
			
		||||
 | 
			
		||||
-- 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
 | 
			
		||||
FROM transactions 
 | 
			
		||||
INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
			
		||||
@@ -33,3 +51,8 @@ DELETE FROM transactions
 | 
			
		||||
USING accounts
 | 
			
		||||
WHERE accounts.budget_id = @budget_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
 | 
			
		||||
INSERT INTO transactions
 | 
			
		||||
(date, memo, amount, account_id, payee_id, category_id)
 | 
			
		||||
VALUES ($1, $2, $3, $4, $5, $6)
 | 
			
		||||
RETURNING id, date, memo, amount, account_id, category_id, payee_id
 | 
			
		||||
(date, memo, amount, account_id, payee_id, category_id, group_id)
 | 
			
		||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
 | 
			
		||||
RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
type CreateTransactionParams struct {
 | 
			
		||||
@@ -24,6 +24,7 @@ type CreateTransactionParams struct {
 | 
			
		||||
	AccountID  uuid.UUID
 | 
			
		||||
	PayeeID    uuid.NullUUID
 | 
			
		||||
	CategoryID uuid.NullUUID
 | 
			
		||||
	GroupID    uuid.NullUUID
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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.PayeeID,
 | 
			
		||||
		arg.CategoryID,
 | 
			
		||||
		arg.GroupID,
 | 
			
		||||
	)
 | 
			
		||||
	var i Transaction
 | 
			
		||||
	err := row.Scan(
 | 
			
		||||
@@ -44,6 +46,7 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
 | 
			
		||||
		&i.AccountID,
 | 
			
		||||
		&i.CategoryID,
 | 
			
		||||
		&i.PayeeID,
 | 
			
		||||
		&i.GroupID,
 | 
			
		||||
	)
 | 
			
		||||
	return i, err
 | 
			
		||||
}
 | 
			
		||||
@@ -63,8 +66,73 @@ func (q *Queries) DeleteAllTransactions(ctx context.Context, budgetID uuid.UUID)
 | 
			
		||||
	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
 | 
			
		||||
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
 | 
			
		||||
FROM transactions 
 | 
			
		||||
INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
			
		||||
@@ -81,6 +149,7 @@ type GetTransactionsForAccountRow struct {
 | 
			
		||||
	Date          time.Time
 | 
			
		||||
	Memo          string
 | 
			
		||||
	Amount        Numeric
 | 
			
		||||
	GroupID       uuid.NullUUID
 | 
			
		||||
	Account       string
 | 
			
		||||
	Payee         string
 | 
			
		||||
	CategoryGroup string
 | 
			
		||||
@@ -101,6 +170,7 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
 | 
			
		||||
			&i.Date,
 | 
			
		||||
			&i.Memo,
 | 
			
		||||
			&i.Amount,
 | 
			
		||||
			&i.GroupID,
 | 
			
		||||
			&i.Account,
 | 
			
		||||
			&i.Payee,
 | 
			
		||||
			&i.CategoryGroup,
 | 
			
		||||
@@ -120,7 +190,7 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
FROM transactions 
 | 
			
		||||
INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
			
		||||
@@ -137,6 +207,7 @@ type GetTransactionsForBudgetRow struct {
 | 
			
		||||
	Date          time.Time
 | 
			
		||||
	Memo          string
 | 
			
		||||
	Amount        Numeric
 | 
			
		||||
	GroupID       uuid.NullUUID
 | 
			
		||||
	Account       string
 | 
			
		||||
	Payee         string
 | 
			
		||||
	CategoryGroup string
 | 
			
		||||
@@ -157,6 +228,7 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
 | 
			
		||||
			&i.Date,
 | 
			
		||||
			&i.Memo,
 | 
			
		||||
			&i.Amount,
 | 
			
		||||
			&i.GroupID,
 | 
			
		||||
			&i.Account,
 | 
			
		||||
			&i.Payee,
 | 
			
		||||
			&i.CategoryGroup,
 | 
			
		||||
@@ -174,3 +246,37 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
 | 
			
		||||
	}
 | 
			
		||||
	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 "new"}}
 | 
			
		||||
    {{template "transaction-new"}}
 | 
			
		||||
    {{template "transaction-new" .}}
 | 
			
		||||
{{end}}
 | 
			
		||||
 | 
			
		||||
{{define "main"}}
 | 
			
		||||
<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>
 | 
			
		||||
</div>
 | 
			
		||||
<table class="container col-lg-12" id="content">
 | 
			
		||||
    {{range .Transactions}}
 | 
			
		||||
    <tr>
 | 
			
		||||
        <td>{{.Date}}</td>
 | 
			
		||||
    <tr class="{{if .Date.After now}}future{{end}}">
 | 
			
		||||
        <td>{{.Date.Format "02.01.2006"}}</td>
 | 
			
		||||
        <td>
 | 
			
		||||
            {{.Account}}
 | 
			
		||||
        </td>
 | 
			
		||||
@@ -27,7 +27,10 @@
 | 
			
		||||
            {{end}}
 | 
			
		||||
        </td>
 | 
			
		||||
        <td>
 | 
			
		||||
            <a href="transaction/{{.ID}}">{{.Memo}}</a>
 | 
			
		||||
            {{if .GroupID.Valid}}☀{{end}}
 | 
			
		||||
        </td>
 | 
			
		||||
        <td>
 | 
			
		||||
            <a href="/budget/{{$.Budget.ID}}/transaction/{{.ID}}">{{.Memo}}</a>
 | 
			
		||||
        </td>
 | 
			
		||||
        {{template "amount-cell" .Amount}}
 | 
			
		||||
    </tr>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,23 @@
 | 
			
		||||
{{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}}
 | 
			
		||||
        </span>
 | 
			
		||||
{{end}}
 | 
			
		||||
 | 
			
		||||
{{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}}
 | 
			
		||||
        </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}}
 | 
			
		||||
							
								
								
									
										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">
 | 
			
		||||
 | 
			
		||||
	    <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" />
 | 
			
		||||
 | 
			
		||||
	    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> 
 | 
			
		||||
@@ -19,19 +18,21 @@
 | 
			
		||||
        {{block "more-head" .}}{{end}}
 | 
			
		||||
    </head>
 | 
			
		||||
    <body>
 | 
			
		||||
        <div id="sidebar">
 | 
			
		||||
            {{block "sidebar" .}}
 | 
			
		||||
                {{template "budget-sidebar" .}}
 | 
			
		||||
            {{end}}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div id="content">
 | 
			
		||||
            <div class="container" id="head">
 | 
			
		||||
                {{template "title" .}}
 | 
			
		||||
        <div id="wrapper">
 | 
			
		||||
            <div id="sidebar">
 | 
			
		||||
                {{block "sidebar" .}}
 | 
			
		||||
                    {{template "budget-sidebar" .}}
 | 
			
		||||
                {{end}}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="container col-lg-12" id="content">
 | 
			
		||||
                    {{template "main" .}}
 | 
			
		||||
            <div id="content">
 | 
			
		||||
                <div class="container" id="head">
 | 
			
		||||
                    {{template "title" .}}
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="container col-lg-12" id="content">
 | 
			
		||||
                        {{template "main" .}}
 | 
			
		||||
                </div>
 | 
			
		||||
                {{block "new" .}}{{end}}
 | 
			
		||||
            </div>
 | 
			
		||||
            {{block "new" .}}{{end}}
 | 
			
		||||
        </div>
 | 
			
		||||
    </body>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{{define "budget-new"}}
 | 
			
		||||
    <div id="newbudgetmodal" class="modal fade">
 | 
			
		||||
    <div id="newbudgetmodal" class="modal fade" role="dialog">
 | 
			
		||||
        <div class="modal-dialog" role="document">
 | 
			
		||||
            <script>        
 | 
			
		||||
                $(document).ready(function () {
 | 
			
		||||
@@ -14,7 +14,7 @@
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
                <div class="modal-header">
 | 
			
		||||
                    <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>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
@@ -30,7 +30,7 @@
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <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" />
 | 
			
		||||
                    </div>
 | 
			
		||||
                </form>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
{{define "budget-sidebar"}}
 | 
			
		||||
<h1><a href="/dashboard">⌂</a> {{.Budget.Name}}</h1>
 | 
			
		||||
<ul>
 | 
			
		||||
        <li><a href="/budget/{{.Budget.ID}}">Budget</a></li>
 | 
			
		||||
        <li>Reports (Coming Soon)</li>
 | 
			
		||||
@@ -6,28 +7,42 @@
 | 
			
		||||
        <li>
 | 
			
		||||
                On-Budget Accounts
 | 
			
		||||
                <ul class="two-valued">
 | 
			
		||||
                        {{range .Accounts}}
 | 
			
		||||
                                <li>
 | 
			
		||||
                                        <a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
 | 
			
		||||
                                        {{template "amount" .Balance}}
 | 
			
		||||
                                </li>
 | 
			
		||||
                        {{end}}
 | 
			
		||||
                        {{- range .OnBudgetAccounts}}
 | 
			
		||||
                        <li>
 | 
			
		||||
                                <a href="/budget/{{$.Budget.ID}}/accounts">Edit accounts</a>
 | 
			
		||||
                                <a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
 | 
			
		||||
                                {{- template "amount" .Balance}}
 | 
			
		||||
                        </li>
 | 
			
		||||
                        {{- end}}
 | 
			
		||||
                </ul>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li>
 | 
			
		||||
                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>
 | 
			
		||||
                Closed Accounts
 | 
			
		||||
        </li>
 | 
			
		||||
        <li>
 | 
			
		||||
                <a href="/budget/{{$.Budget.ID}}/accounts">Edit accounts</a>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li>
 | 
			
		||||
                + Add Account
 | 
			
		||||
        </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>
 | 
			
		||||
</ul>        
 | 
			
		||||
{{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}}
 | 
			
		||||
 | 
			
		||||
{{define "new"}}
 | 
			
		||||
    {{template "transaction-new"}}
 | 
			
		||||
{{end}}
 | 
			
		||||
 | 
			
		||||
{{define "main"}}
 | 
			
		||||
<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>
 | 
			
		||||
</div>
 | 
			
		||||
<div>
 | 
			
		||||
@@ -19,14 +18,19 @@
 | 
			
		||||
    <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>
 | 
			
		||||
</div>
 | 
			
		||||
<div>
 | 
			
		||||
    <span>Available Balance: </span>{{template "amountf64" .AvailableBalance}}
 | 
			
		||||
</div>
 | 
			
		||||
<table class="container col-lg-12" id="content">
 | 
			
		||||
    <tr>
 | 
			
		||||
        <th>Group</th>
 | 
			
		||||
        <th>Category</th>
 | 
			
		||||
        <th></th>
 | 
			
		||||
        <th></th>
 | 
			
		||||
        <th>Balance</th>
 | 
			
		||||
        <th>Leftover</th>
 | 
			
		||||
        <th>Assigned</th>
 | 
			
		||||
        <th>Activity</th>
 | 
			
		||||
        <th>Available</th>
 | 
			
		||||
    </tr>
 | 
			
		||||
    {{range .Categories}}
 | 
			
		||||
    <tr>
 | 
			
		||||
@@ -36,8 +40,10 @@
 | 
			
		||||
        </td>
 | 
			
		||||
        <td>
 | 
			
		||||
        </td>
 | 
			
		||||
        {{template "amount-cell" .Balance}}
 | 
			
		||||
        {{template "amount-cell" .Activity}}
 | 
			
		||||
        {{template "amountf64-cell" .AvailableLastMonth}}
 | 
			
		||||
        {{template "amountf64-cell" .Assigned}}
 | 
			
		||||
        {{template "amountf64-cell" .Activity}}
 | 
			
		||||
        {{template "amountf64-cell" .Available}}
 | 
			
		||||
    </tr>
 | 
			
		||||
    {{end}}
 | 
			
		||||
</table>
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@
 | 
			
		||||
    </div>
 | 
			
		||||
    {{end}}
 | 
			
		||||
    <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>
 | 
			
		||||
    </div>
 | 
			
		||||
{{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 database</a>
 | 
			
		||||
        <p>This removes all data and starts from scratch. 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 {
 | 
			
		||||
    height:160px;
 | 
			
		||||
    line-height: 160px;
 | 
			
		||||
@@ -33,7 +37,7 @@
 | 
			
		||||
    font-size: 70.7%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
#wrapper {
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-template-columns: 300px auto;
 | 
			
		||||
}
 | 
			
		||||
@@ -61,6 +65,13 @@ body {
 | 
			
		||||
    text-align: right;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Highlights */
 | 
			
		||||
.negative {
 | 
			
		||||
    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-header">
 | 
			
		||||
                    <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>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
                <form id="newtransactionform" action="/api/v1/transaction/new" 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="">-- none --</option>
 | 
			
		||||
                                {{range .Categories}}
 | 
			
		||||
                                    <option value="{{.ID}}">{{.Group}} : {{.Name}}</option>
 | 
			
		||||
                                {{end}}
 | 
			
		||||
                            </select>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="form-group">
 | 
			
		||||
                            <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 class="form-group">
 | 
			
		||||
                            <label for="memo">Memo</label>
 | 
			
		||||
@@ -34,7 +44,7 @@
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <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" />
 | 
			
		||||
                    </div>
 | 
			
		||||
                </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