aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatarLibravatar Biswa Kalyan Bhuyan <biswa@surgot.in> 2025-04-24 08:18:27 +0530
committerLibravatarLibravatar Biswa Kalyan Bhuyan <biswa@surgot.in> 2025-04-24 08:18:27 +0530
commit50d5e6534f5e593297a09323e683c7c8b850117b (patch)
tree339d6e8b123c5d4caa4129971e2cb1b960b12a89
parent76066679b5bdab53419492066c4e80d2ed3be518 (diff)
downloadfinance-50d5e6534f5e593297a09323e683c7c8b850117b.tar.gz
finance-50d5e6534f5e593297a09323e683c7c8b850117b.tar.bz2
finance-50d5e6534f5e593297a09323e683c7c8b850117b.zip
feat: added basic backend features to it
- Set up API framework (Gin Gonic) - Set up ORM/DB library (GORM) - Design database schema (Users, Accounts, Transactions, Loans, Goals) - Set up database connection and migrations
-rw-r--r--.gitignore6
-rw-r--r--README.md10
-rw-r--r--backend/cmd/api/main.go110
-rw-r--r--backend/go.mod43
-rw-r--r--backend/go.sum109
-rw-r--r--backend/internal/api/auth/auth.go247
-rw-r--r--backend/internal/api/v1/loans/loans.go248
-rw-r--r--backend/internal/api/v1/users/handler.go19
-rw-r--r--backend/internal/config/config.go54
-rw-r--r--backend/internal/database/database.go61
-rw-r--r--backend/internal/models/models.go97
-rw-r--r--backend/internal/router/router.go88
-rwxr-xr-xbackend/scripts/setup_db.sh38
13 files changed, 1124 insertions, 6 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..462a796
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.env
+.env.local
+.env.development
+.env.production
+.env.test
+.env.test.local \ No newline at end of file
diff --git a/README.md b/README.md
index 3f65393..555301d 100644
--- a/README.md
+++ b/README.md
@@ -81,14 +81,14 @@ An application designed to help manage personal finances, including income (like
**Phase 1: Backend Core Setup (Go)**
* [x] Initialize Go project (`go mod init finance/backend`)
-* [ ] Set up API framework (Gin Gonic)
-* [ ] Set up ORM/DB library (GORM)
-* [ ] Design database schema (Users, Accounts, Transactions, Loans, Goals)
-* [ ] Set up database connection and migrations
+* [x] Set up API framework (Gin Gonic)
+* [x] Set up ORM/DB library (GORM)
+* [x] Design database schema (Users, Accounts, Transactions, Loans, Goals)
+* [x] Set up database connection and migrations
* [ ] Implement User Authentication (Signup, Login, JWT/Session Management)
* [ ] Create basic CRUD APIs for Accounts (e.g., Salary Source, Bank Account)
* [ ] Create basic CRUD APIs for Transactions (Income, Expense)
-* [ ] Create basic CRUD APIs for Loans
+* [x] Create basic CRUD APIs for Loans
* [ ] Create basic CRUD APIs for Goals
* [ ] Set up initial logging and error handling
* [ ] Write unit/integration tests for core API endpoints
diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go
new file mode 100644
index 0000000..de021fa
--- /dev/null
+++ b/backend/cmd/api/main.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "log"
+ "net/http"
+
+ "finance/backend/internal/api/auth"
+ "finance/backend/internal/api/v1/loans"
+ "finance/backend/internal/config"
+ "finance/backend/internal/database"
+
+ "github.com/gin-gonic/gin"
+)
+
+func main() {
+ // Load Configuration
+ cfg, err := config.LoadConfig()
+ if err != nil {
+ log.Fatalf("Failed to load configuration: %v", err)
+ }
+
+ // Initialize Database
+ if err := database.InitDatabase(cfg); err != nil {
+ log.Fatalf("Failed to initialize database: %v", err)
+ }
+
+ // Setup Gin Router
+ r := gin.Default()
+
+ // Public utility endpoints
+ r.GET("/ping", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "message": "pong",
+ })
+ })
+
+ // Add database status endpoint
+ r.GET("/db-status", func(c *gin.Context) {
+ // Try to get a connection from the pool
+ sqlDB, err := database.DB.DB()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "status": "error",
+ "message": "Failed to get database connection",
+ "error": err.Error(),
+ })
+ return
+ }
+
+ // Check if database is reachable
+ err = sqlDB.Ping()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "status": "error",
+ "message": "Database is not reachable",
+ "error": err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "status": "success",
+ "message": "Database connection is healthy",
+ })
+ })
+
+ // API v1 routes
+ v1 := r.Group("/api/v1")
+ {
+ // Auth routes (public)
+ v1.POST("/auth/signup", auth.Signup(cfg))
+ v1.POST("/auth/login", auth.Login(cfg))
+
+ // Protected routes
+ protected := v1.Group("")
+ protected.Use(auth.AuthMiddleware(cfg))
+ {
+ // User routes
+ protected.GET("/users/me", func(c *gin.Context) {
+ // Get user from context (set by auth middleware)
+ user, exists := c.Get("user")
+ if !exists {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"user": user})
+ })
+
+ // Loan routes
+ loanRoutes := protected.Group("/loans")
+ {
+ loanRoutes.GET("", loans.GetLoans())
+ loanRoutes.GET("/:id", loans.GetLoanByID())
+ loanRoutes.POST("", loans.CreateLoan())
+ loanRoutes.PUT("/:id", loans.UpdateLoan())
+ loanRoutes.DELETE("/:id", loans.DeleteLoan())
+ }
+ }
+ }
+
+ // Run the server
+ serverAddr := ":8080" // TODO: Make this configurable via cfg
+ log.Printf("Starting server on %s", serverAddr)
+ err = r.Run(serverAddr)
+ if err != nil {
+ // Use Fatalf to exit if server fails to start
+ log.Fatalf("Failed to start server: %v", err)
+ }
+}
diff --git a/backend/go.mod b/backend/go.mod
index 66c3e08..65e18cf 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -1,3 +1,44 @@
-module finance/baq
+module finance/backend
go 1.24.2
+
+require (
+ github.com/bytedance/sonic v1.11.6 // indirect
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/gin-gonic/gin v1.10.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/pgx/v5 v5.5.5 // indirect
+ github.com/jackc/puddle/v2 v2.2.1 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/joho/godotenv v1.5.1 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/crypto v0.37.0 // indirect
+ golang.org/x/net v0.25.0 // indirect
+ golang.org/x/sync v0.13.0 // indirect
+ golang.org/x/sys v0.32.0 // indirect
+ golang.org/x/text v0.24.0 // indirect
+ google.golang.org/protobuf v1.34.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ gorm.io/driver/postgres v1.5.11 // indirect
+ gorm.io/gorm v1.25.12 // indirect
+)
diff --git a/backend/go.sum b/backend/go.sum
new file mode 100644
index 0000000..beb26dd
--- /dev/null
+++ b/backend/go.sum
@@ -0,0 +1,109 @@
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
+github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
+gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/backend/internal/api/auth/auth.go b/backend/internal/api/auth/auth.go
new file mode 100644
index 0000000..2f8fa6a
--- /dev/null
+++ b/backend/internal/api/auth/auth.go
@@ -0,0 +1,247 @@
+package auth
+
+import (
+ "finance/backend/internal/config"
+ "finance/backend/internal/database"
+ "finance/backend/internal/models"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// Request and Response Types
+
+// SignupRequest represents the data needed for registering a new user
+type SignupRequest struct {
+ Name string `json:"name" binding:"required"`
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required,min=8"`
+}
+
+// LoginRequest represents the data needed for user login
+type LoginRequest struct {
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required"`
+}
+
+// AuthResponse is returned on successful authentication
+type AuthResponse struct {
+ Token string `json:"token"`
+ User models.User `json:"user"`
+}
+
+// JWT Token Functions
+
+// GenerateJWT creates a new JWT token for a given user ID.
+func GenerateJWT(userID uint, cfg *config.Config) (string, error) {
+ // Create the claims
+ claims := jwt.MapClaims{
+ "sub": userID, // Subject (user ID)
+ "iss": "finance-app", // Issuer
+ "aud": "finance-app-users", // Audience
+ "exp": time.Now().Add(time.Hour * 24).Unix(), // Expiration time (e.g., 24 hours)
+ "iat": time.Now().Unix(), // Issued at
+ }
+
+ // Create token
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+
+ // Sign token with secret
+ signedToken, err := token.SignedString([]byte(cfg.JWTSecret))
+ if err != nil {
+ return "", fmt.Errorf("failed to sign token: %w", err)
+ }
+
+ return signedToken, nil
+}
+
+// ValidateJWT validates a JWT token and extracts the user ID from it.
+// Returns the user ID if the token is valid, or an error if it's not.
+func ValidateJWT(tokenString string, cfg *config.Config) (uint, error) {
+ // Parse the token
+ token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
+ // Validate the signing method
+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+ }
+
+ // Return the secret key
+ return []byte(cfg.JWTSecret), nil
+ })
+
+ if err != nil {
+ return 0, fmt.Errorf("failed to parse token: %w", err)
+ }
+
+ // Check if token is valid
+ if !token.Valid {
+ return 0, fmt.Errorf("invalid token")
+ }
+
+ // Extract claims
+ claims, ok := token.Claims.(jwt.MapClaims)
+ if !ok {
+ return 0, fmt.Errorf("invalid token claims")
+ }
+
+ // Verify expiration time
+ if exp, ok := claims["exp"].(float64); ok {
+ if time.Now().Unix() > int64(exp) {
+ return 0, fmt.Errorf("token expired")
+ }
+ } else {
+ return 0, fmt.Errorf("invalid expiration claim")
+ }
+
+ // Extract user ID from subject claim
+ sub, ok := claims["sub"].(float64)
+ if !ok {
+ return 0, fmt.Errorf("invalid subject claim")
+ }
+
+ // Convert to uint and return
+ return uint(sub), nil
+}
+
+// Handler Functions
+
+// Signup handles user registration
+func Signup(cfg *config.Config) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req SignupRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Check if user with this email already exists
+ var existingUser models.User
+ result := database.DB.Where("email = ?", req.Email).First(&existingUser)
+ if result.RowsAffected > 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "User with this email already exists"})
+ return
+ }
+
+ // Create new user
+ user := models.User{
+ Name: req.Name,
+ Email: req.Email,
+ }
+
+ // Set password (this will hash it)
+ if err := user.SetPassword(req.Password); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process password"})
+ return
+ }
+
+ // Save user to database
+ if err := database.DB.Create(&user).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
+ return
+ }
+
+ // Generate JWT token
+ token, err := GenerateJWT(user.ID, cfg)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate authentication token"})
+ return
+ }
+
+ // Clear password hash before returning user data
+ user.PasswordHash = ""
+
+ c.JSON(http.StatusCreated, AuthResponse{
+ Token: token,
+ User: user,
+ })
+ }
+}
+
+// Login handles user authentication
+func Login(cfg *config.Config) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req LoginRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Find user by email
+ var user models.User
+ result := database.DB.Where("email = ?", req.Email).First(&user)
+ if result.Error != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
+ return
+ }
+
+ // Check password
+ if err := user.CheckPassword(req.Password); err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
+ return
+ }
+
+ // Generate JWT token
+ token, err := GenerateJWT(user.ID, cfg)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate authentication token"})
+ return
+ }
+
+ // Clear password hash before returning user data
+ user.PasswordHash = ""
+
+ c.JSON(http.StatusOK, AuthResponse{
+ Token: token,
+ User: user,
+ })
+ }
+}
+
+// AuthMiddleware validates JWT tokens and adds user data to the context
+func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ authHeader := c.GetHeader("Authorization")
+ if authHeader == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
+ c.Abort()
+ return
+ }
+
+ // Check if the Authorization header has the Bearer prefix
+ parts := strings.Split(authHeader, " ")
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
+ c.Abort()
+ return
+ }
+
+ // Extract the token
+ tokenString := parts[1]
+
+ // Validate the token
+ userID, err := ValidateJWT(tokenString, cfg)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
+ c.Abort()
+ return
+ }
+
+ // Find the user in the database
+ var user models.User
+ if err := database.DB.First(&user, userID).Error; err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
+ c.Abort()
+ return
+ }
+
+ // Set user data in context
+ c.Set("userID", userID)
+ c.Set("user", user)
+
+ c.Next()
+ }
+}
diff --git a/backend/internal/api/v1/loans/loans.go b/backend/internal/api/v1/loans/loans.go
new file mode 100644
index 0000000..06d96b0
--- /dev/null
+++ b/backend/internal/api/v1/loans/loans.go
@@ -0,0 +1,248 @@
+package loans
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "finance/backend/internal/database"
+ "finance/backend/internal/models"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// GetLoans returns all loans for the authenticated user
+func GetLoans() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Get user from context (set by auth middleware)
+ user, exists := c.Get("user")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ userObj := user.(*models.User)
+ var loans []models.Loan
+
+ // Fetch all loans for the user
+ if err := database.DB.Where("user_id = ?", userObj.ID).Find(&loans).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loans"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"loans": loans})
+ }
+}
+
+// GetLoanByID returns a specific loan by ID
+func GetLoanByID() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Get user from context (set by auth middleware)
+ user, exists := c.Get("user")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+ userObj := user.(*models.User)
+
+ // Get loan ID from URL parameter
+ loanID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"})
+ return
+ }
+
+ var loan models.Loan
+
+ // Fetch the loan and ensure it belongs to the authenticated user
+ if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"})
+ } else {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"})
+ }
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"loan": loan})
+ }
+}
+
+// CreateLoan creates a new loan
+func CreateLoan() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Get user from context (set by auth middleware)
+ user, exists := c.Get("user")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+ userObj := user.(*models.User)
+
+ // Define a struct to bind the request JSON
+ var input struct {
+ Name string `json:"name" binding:"required"`
+ OriginalAmount int64 `json:"originalAmount" binding:"required"`
+ CurrentBalance int64 `json:"currentBalance" binding:"required"`
+ InterestRate float64 `json:"interestRate"`
+ StartDate string `json:"startDate" binding:"required"`
+ EndDate string `json:"endDate" binding:"required"`
+ AccountID *uint `json:"accountId"`
+ }
+
+ // Bind JSON to struct
+ if err := c.ShouldBindJSON(&input); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Parse dates
+ startDate, err := time.Parse("2006-01-02", input.StartDate)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start date format"})
+ return
+ }
+
+ endDate, err := time.Parse("2006-01-02", input.EndDate)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format"})
+ return
+ }
+
+ // Create loan object
+ loan := models.Loan{
+ UserID: userObj.ID,
+ Name: input.Name,
+ OriginalAmount: input.OriginalAmount,
+ CurrentBalance: input.CurrentBalance,
+ InterestRate: input.InterestRate,
+ StartDate: startDate,
+ EndDate: endDate,
+ AccountID: input.AccountID,
+ }
+
+ // Save to database
+ if err := database.DB.Create(&loan).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create loan"})
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{"loan": loan})
+ }
+}
+
+// UpdateLoan updates an existing loan
+func UpdateLoan() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Get user from context (set by auth middleware)
+ user, exists := c.Get("user")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+ userObj := user.(*models.User)
+
+ // Get loan ID from URL parameter
+ loanID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"})
+ return
+ }
+
+ // Check if the loan exists and belongs to the user
+ var loan models.Loan
+ if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"})
+ } else {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"})
+ }
+ return
+ }
+
+ // Define a struct to bind the request JSON
+ var input struct {
+ Name string `json:"name"`
+ CurrentBalance int64 `json:"currentBalance"`
+ InterestRate float64 `json:"interestRate"`
+ EndDate string `json:"endDate"`
+ AccountID *uint `json:"accountId"`
+ }
+
+ // Bind JSON to struct
+ if err := c.ShouldBindJSON(&input); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Update fields if provided
+ if input.Name != "" {
+ loan.Name = input.Name
+ }
+ if input.CurrentBalance != 0 {
+ loan.CurrentBalance = input.CurrentBalance
+ }
+ if input.InterestRate != 0 {
+ loan.InterestRate = input.InterestRate
+ }
+ if input.EndDate != "" {
+ endDate, err := time.Parse("2006-01-02", input.EndDate)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format"})
+ return
+ }
+ loan.EndDate = endDate
+ }
+ if input.AccountID != nil {
+ loan.AccountID = input.AccountID
+ }
+
+ // Save updates to database
+ if err := database.DB.Save(&loan).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update loan"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"loan": loan})
+ }
+}
+
+// DeleteLoan deletes a loan
+func DeleteLoan() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Get user from context (set by auth middleware)
+ user, exists := c.Get("user")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+ userObj := user.(*models.User)
+
+ // Get loan ID from URL parameter
+ loanID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"})
+ return
+ }
+
+ // Check if the loan exists and belongs to the user
+ var loan models.Loan
+ if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"})
+ } else {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"})
+ }
+ return
+ }
+
+ // Delete the loan
+ if err := database.DB.Delete(&loan).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete loan"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Loan deleted successfully"})
+ }
+}
diff --git a/backend/internal/api/v1/users/handler.go b/backend/internal/api/v1/users/handler.go
new file mode 100644
index 0000000..9e53147
--- /dev/null
+++ b/backend/internal/api/v1/users/handler.go
@@ -0,0 +1,19 @@
+package users
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// GetCurrentUser returns the authenticated user's information
+func GetCurrentUser(c *gin.Context) {
+ // Get user from context (set by auth middleware)
+ user, exists := c.Get("user")
+ if !exists {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"user": user})
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
new file mode 100644
index 0000000..f61f423
--- /dev/null
+++ b/backend/internal/config/config.go
@@ -0,0 +1,54 @@
+package config
+
+import (
+ "log"
+ "os"
+
+ "github.com/joho/godotenv"
+)
+
+// Config holds the application configuration
+type Config struct {
+ DatabaseDSN string
+ JWTSecret string // Secret key for signing JWTs
+ // Add other config fields here later (e.g., server port)
+}
+
+// LoadConfig loads configuration from environment variables or a .env file
+func LoadConfig() (*Config, error) {
+ // Attempt to load .env file (useful for development)
+ // In production, rely on environment variables set directly
+ err := godotenv.Load() // Load .env file from current directory or parent dirs
+ if err != nil {
+ log.Println("No .env file found, relying on environment variables")
+ }
+
+ dsn := os.Getenv("DATABASE_DSN")
+ if dsn == "" {
+ // Set a default for local development if not provided
+ log.Println("DATABASE_DSN not set, using default local PostgreSQL DSN")
+ log.Println("IMPORTANT: Ensure PostgreSQL is running with the correct credentials.")
+ log.Println("Try creating a database 'finance' and setting up a user with the correct permissions.")
+ log.Println("Example commands:")
+ log.Println(" createdb finance")
+ log.Println(" createuser -P -s -e user_name")
+
+ // Use more common/generic default credentials
+ dsn = "host=localhost user=postgres password=postgres dbname=finance port=5432 sslmode=disable TimeZone=UTC"
+ // Consider making the default conditional or removing it for stricter environments
+ }
+
+ jwtSecret := os.Getenv("JWT_SECRET")
+ if jwtSecret == "" {
+ log.Println("WARNING: JWT_SECRET environment variable not set. Using an insecure default.")
+ // !!! IMPORTANT: Use a strong, randomly generated secret in production !!!
+ // For development only:
+ jwtSecret = "insecure-default-dev-secret-change-me"
+ // return nil, errors.New("JWT_SECRET environment variable is required")
+ }
+
+ return &Config{
+ DatabaseDSN: dsn,
+ JWTSecret: jwtSecret,
+ }, nil
+}
diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go
new file mode 100644
index 0000000..15228e2
--- /dev/null
+++ b/backend/internal/database/database.go
@@ -0,0 +1,61 @@
+package database
+
+import (
+ "log"
+ "time"
+
+ "finance/backend/internal/config"
+ "finance/backend/internal/models"
+
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+var DB *gorm.DB
+
+// InitDatabase initializes the database connection and runs migrations.
+func InitDatabase(cfg *config.Config) error {
+ var err error
+ log.Println("Connecting to database...")
+
+ // Configure GORM logger
+ // You might want to adjust the log level based on environment (e.g., Silent in production)
+ newLogger := logger.New(
+ log.New(log.Writer(), "\r\n", log.LstdFlags), // io writer
+ logger.Config{
+ SlowThreshold: 200 * time.Millisecond, // Explicitly set slow query threshold
+ LogLevel: logger.Info, // Log all SQL
+ IgnoreRecordNotFoundError: true,
+ Colorful: false, // Disable color in logs, or true if terminal supports
+ },
+ )
+
+ DB, err = gorm.Open(postgres.Open(cfg.DatabaseDSN), &gorm.Config{
+ Logger: newLogger,
+ })
+
+ if err != nil {
+ log.Printf("Failed to connect to database: %v\n", err)
+ return err
+ }
+
+ log.Println("Database connection established.")
+
+ // Run Migrations
+ log.Println("Running database migrations...")
+ err = DB.AutoMigrate(
+ &models.User{},
+ &models.Account{},
+ &models.Transaction{},
+ &models.Loan{},
+ &models.Goal{},
+ )
+ if err != nil {
+ log.Printf("Failed to run migrations: %v\n", err)
+ return err
+ }
+
+ log.Println("Database migrations completed.")
+ return nil
+}
diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go
new file mode 100644
index 0000000..c984012
--- /dev/null
+++ b/backend/internal/models/models.go
@@ -0,0 +1,97 @@
+package models
+
+import (
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+)
+
+// User represents a user in the system
+// We'll store a hashed password, not the plaintext one.
+type User struct {
+ gorm.Model // Includes ID, CreatedAt, UpdatedAt, DeletedAt
+ Name string `gorm:"not null"`
+ Email string `gorm:"uniqueIndex;not null"` // Unique email
+ PasswordHash string `gorm:"not null"` // Store hashed password
+}
+
+// SetPassword hashes the given password and sets it on the user model.
+func (u *User) SetPassword(password string) error {
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ u.PasswordHash = string(hashedPassword)
+ return nil
+}
+
+// CheckPassword compares a given password with the user's hashed password.
+// Returns nil if the password is correct, otherwise returns an error.
+func (u *User) CheckPassword(password string) error {
+ return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
+}
+
+// Account represents a financial account (e.g., bank account, credit card, cash)
+// It belongs to a User.
+type Account struct {
+ gorm.Model
+ UserID uint `gorm:"not null;index"` // Foreign key to User
+ User User // Belongs To relationship
+ Name string `gorm:"not null"`
+ Type string `gorm:"not null;index"` // e.g., "Bank", "Credit Card", "Cash", "Loan", "Income Source"
+ // Balance is stored in the smallest currency unit (e.g., cents)
+ // to avoid floating point issues.
+ Balance int64 `gorm:"not null;default:0"`
+}
+
+// Transaction represents an income or expense event.
+// It belongs to a User and is usually associated with an Account.
+type Transaction struct {
+ gorm.Model
+ UserID uint `gorm:"not null;index"` // Foreign key to User
+ User User // Belongs To relationship
+ AccountID *uint `gorm:"index"` // Foreign key to Account (nullable, e.g., for cash transactions not tied to a bank)
+ Account *Account // Belongs To relationship (pointer for nullable FK)
+ Description string `gorm:"not null"`
+ // Amount stored in the smallest currency unit (e.g., cents)
+ Amount int64 `gorm:"not null"`
+ Type string `gorm:"not null;index"` // "Income" or "Expense"
+ Date time.Time `gorm:"not null;index"` // Date of the transaction
+ Category string `gorm:"index"` // e.g., "Salary", "Groceries", "Utilities"
+}
+
+// Loan represents a loan taken by the user.
+// It belongs to a User and may be linked to an Account representing the loan source/payments.
+type Loan struct {
+ gorm.Model
+ UserID uint `gorm:"not null;index"` // Foreign key to User
+ User User // Belongs To relationship
+ AccountID *uint `gorm:"index"` // Optional FK to the related Account (e.g., where payments are made from/to)
+ Account *Account // Belongs To relationship
+ Name string `gorm:"not null"` // e.g., "Car Loan", "Student Loan"
+ OriginalAmount int64 `gorm:"not null"` // In smallest currency unit
+ CurrentBalance int64 `gorm:"not null"` // In smallest currency unit
+ InterestRate float64 // Annual interest rate (e.g., 0.05 for 5%)
+ StartDate time.Time
+ EndDate time.Time // Expected payoff date
+ // Add fields for payment frequency, next due date etc. if needed later
+}
+
+// Goal represents a financial goal the user is working towards.
+// It belongs to a User.
+type Goal struct {
+ gorm.Model
+ UserID uint `gorm:"not null;index"` // Foreign key to User
+ User User // Belongs To relationship
+ Name string `gorm:"not null"` // e.g., "Save for Down Payment", "Pay Off Credit Card"
+ TargetAmount int64 `gorm:"not null"` // In smallest currency unit
+ CurrentAmount int64 `gorm:"not null;default:0"` // In smallest currency unit
+ TargetDate time.Time // Optional target date for the goal
+ Status string `gorm:"not null;default:'Active';index"` // e.g., "Active", "Achieved", "Cancelled"
+ // Link to specific accounts or loans if needed (e.g., goal to pay off a specific loan)
+ // RelatedAccountID *uint
+ // RelatedLoanID *uint
+}
+
+// Add other models below (Goal)
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
new file mode 100644
index 0000000..b82e1af
--- /dev/null
+++ b/backend/internal/router/router.go
@@ -0,0 +1,88 @@
+package router
+
+import (
+ "finance/backend/internal/api/auth"
+ "finance/backend/internal/api/v1/users"
+ "finance/backend/internal/config"
+ "finance/backend/internal/database"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// SetupRouter configures the API routes
+func SetupRouter(cfg *config.Config) *gin.Engine {
+ r := gin.Default()
+
+ // Enable CORS
+ r.Use(func(c *gin.Context) {
+ c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
+ c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
+ c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
+ c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
+
+ if c.Request.Method == "OPTIONS" {
+ c.AbortWithStatus(204)
+ return
+ }
+
+ c.Next()
+ })
+
+ // Public utility endpoints
+ r.GET("/ping", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "message": "pong",
+ })
+ })
+
+ // Add database status endpoint
+ r.GET("/db-status", func(c *gin.Context) {
+ // Try to get a connection from the pool
+ sqlDB, err := database.DB.DB()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "status": "error",
+ "message": "Failed to get database connection",
+ "error": err.Error(),
+ })
+ return
+ }
+
+ // Check if database is reachable
+ err = sqlDB.Ping()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "status": "error",
+ "message": "Database is not reachable",
+ "error": err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "status": "success",
+ "message": "Database connection is healthy",
+ })
+ })
+
+ // API v1 routes
+ v1 := r.Group("/api/v1")
+ {
+ // Auth routes (public)
+ v1.POST("/auth/signup", auth.Signup(cfg))
+ v1.POST("/auth/login", auth.Login(cfg))
+
+ // Protected routes
+ protected := v1.Group("")
+ protected.Use(auth.AuthMiddleware(cfg))
+ {
+ // User routes
+ protected.GET("/users/me", users.GetCurrentUser)
+
+ // Add other protected routes here
+ }
+ }
+
+ return r
+}
diff --git a/backend/scripts/setup_db.sh b/backend/scripts/setup_db.sh
new file mode 100755
index 0000000..7388015
--- /dev/null
+++ b/backend/scripts/setup_db.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+
+# Script to set up the PostgreSQL database for the finance application
+
+# Prompt for PostgreSQL user to use
+read -p "Enter PostgreSQL admin username [postgres]: " PG_USER
+PG_USER=${PG_USER:-postgres}
+
+# Prompt for PostgreSQL password
+read -s -p "Enter PostgreSQL password for $PG_USER: " PG_PASSWORD
+echo
+
+# Check if database exists
+echo "Checking if database exists..."
+if PGPASSWORD="$PG_PASSWORD" psql -U "$PG_USER" -h localhost -lqt | cut -d \| -f 1 | grep -qw finance; then
+ echo "Database 'finance' already exists"
+else
+ echo "Creating database 'finance'..."
+ PGPASSWORD="$PG_PASSWORD" createdb -U "$PG_USER" -h localhost finance
+ if [ $? -ne 0 ]; then
+ echo "Failed to create database. Please check your credentials."
+ exit 1
+ fi
+ echo "Database 'finance' created successfully"
+fi
+
+# Create .env file with database connection details
+echo "Creating .env file with database connection info..."
+cat > .env <<EOF
+# Database Connection
+DATABASE_DSN=host=localhost user=$PG_USER password=$PG_PASSWORD dbname=finance port=5432 sslmode=disable TimeZone=UTC
+
+# JWT Secret (for user authentication)
+JWT_SECRET=$(openssl rand -hex 32)
+EOF
+
+echo ".env file created with your database connection details."
+echo "You can now run the application with 'go run cmd/api/main.go'" \ No newline at end of file