diff --git a/cmd/fun-banking/main.go b/cmd/fun-banking/main.go index 498319c..65cc697 100644 --- a/cmd/fun-banking/main.go +++ b/cmd/fun-banking/main.go @@ -1,9 +1,16 @@ package main import ( + "fmt" + "time" + "github.com/bytebury/fun-banking/internal/api" + "github.com/bytebury/fun-banking/internal/domain" "github.com/bytebury/fun-banking/internal/infrastructure/persistence" + "github.com/bytebury/fun-banking/internal/service" "github.com/joho/godotenv" + "github.com/robfig/cron/v3" + "gorm.io/gorm" ) func main() { @@ -12,5 +19,41 @@ func main() { } persistence.Connect() persistence.RunMigrations() + + // Cron Jobs + c := cron.New() + + // Every day at midnight + c.AddFunc("0 0 * * *", func() { + fulfillAutoPay() + }) + + c.Start() + api.Start() } + +func fulfillAutoPay() { + persistence.DB.Transaction(func(tx *gorm.DB) error { + var autoPays []domain.AutoPay + + persistence.DB. + Where("active = 1"). + Where("strftime('%Y-%m-%d', next_run_date) <= ?", time.Now().Format("2006-01-02")). + Find(&autoPays) + + if len(autoPays) == 0 { + return nil + } + + transactionService := service.NewTransactionService() + + for _, autoPay := range autoPays { + if err := transactionService.AutoPay(autoPay); err != nil { + fmt.Println("Error! Auto Pay failed...") + } + } + + return nil + }) +} diff --git a/go.mod b/go.mod index 85dc478..0b879bf 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/plutov/paypal/v4 v4.10.1 + github.com/robfig/cron/v3 v3.0.1 github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/yuin/goldmark v1.7.4 diff --git a/go.sum b/go.sum index 9c9ff24..a160160 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/plutov/paypal/v4 v4.10.1 h1:2eyhpnvRIXWDwiglfajk5TpFFYyF5tKEc3lCXgkcw github.com/plutov/paypal/v4 v4.10.1/go.mod h1:uvznSL1/ZG53kMklaW/sc/X0UjAJ3e1tw9ExvLi6kiI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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= diff --git a/internal/api/handler/account_handler.go b/internal/api/handler/account_handler.go index 83cd1f7..ff6a2ac 100644 --- a/internal/api/handler/account_handler.go +++ b/internal/api/handler/account_handler.go @@ -16,6 +16,7 @@ import ( type accountHandler struct { pageObject Account domain.Account + AutoPays []domain.AutoPay PagingInfo pagination.PagingInfo[domain.Transaction] StatementPeriod string LastTwelveMonths [][]string @@ -28,6 +29,7 @@ type accountHandler struct { func NewAccountHandler() accountHandler { return accountHandler{ Account: domain.Account{}, + AutoPays: []domain.AutoPay{}, PagingInfo: pagination.PagingInfo[domain.Transaction]{}, StatementPeriod: "", LastTwelveMonths: make([][]string, 0), diff --git a/internal/api/handler/auto_pay_handler.go b/internal/api/handler/auto_pay_handler.go new file mode 100644 index 0000000..273451f --- /dev/null +++ b/internal/api/handler/auto_pay_handler.go @@ -0,0 +1,104 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/bytebury/fun-banking/internal/domain" + "github.com/bytebury/fun-banking/internal/infrastructure/persistence" + "github.com/gin-gonic/gin" +) + +func (h accountHandler) OpenAutoPayModal(c *gin.Context) { + h.Reset(c) + + yesterday := time.Now().Format("2006-01-02") + + h.ModalType = "create_auto_pay" + h.Form.Data["min_date"] = yesterday + + if err := h.accountService.FindByID(c.Param("id"), &h.Account); err != nil { + c.HTML(http.StatusNotFound, "not-found", nil) + return + } + + c.HTML(http.StatusOK, "modal", h) +} + +func (h accountHandler) AutoPay(c *gin.Context) { + h.Reset(c) + + if err := h.accountService.FindByID(c.Param("id"), &h.Account); err != nil { + c.HTML(http.StatusNotFound, "not-found", nil) + return + } + + persistence.DB.Find(&h.AutoPays, "account_id = ?", c.Param("id")) + + c.HTML(http.StatusOK, "auto_pay.html", h) +} + +func (h accountHandler) CreateAutoPay(c *gin.Context) { + h.Reset(c) + h.ModalType = "create_auto_pay" + + startDate, startDateErr := time.Parse("2006-01-02", h.Form.Data["start_date"]) + amount, amountErr := strconv.ParseFloat(h.Form.Data["amount"], 64) + accountId, _ := strconv.Atoi(c.Param("id")) + + if startDateErr != nil { + h.Form.Errors["start_date"] = "You provided an invalid start date" + c.HTML(http.StatusUnprocessableEntity, "create_auto_pay_form.html", h) + return + } + + if amountErr != nil { + h.Form.Errors["amount"] = "Invalid value for amount" + c.HTML(http.StatusUnprocessableEntity, "create_auto_pay_form.html", h) + return + } + + if h.Form.Data["type"] == "withdraw" { + amount *= -1 + } + + autoPay := domain.AutoPay{ + Cadence: h.Form.Data["cadence"], + StartDate: startDate, + NextRunDate: startDate, + Amount: amount, + Description: h.Form.Data["description"], + AccountID: accountId, + Active: true, + } + + // TODO: Move to service + if err := persistence.DB.Create(&autoPay).Error; err != nil { + h.Form.Errors["general"] = "Something went wrong creating your Auto Pay Rule" + c.HTML(http.StatusUnprocessableEntity, "create_auto_pay_form.html", h) + return + } + + persistence.DB.Find(&h.AutoPays, "account_id = ?", c.Param("id")) + h.accountService.FindByID(c.Param("id"), &h.Account) + + c.Header("HX-Trigger", "closeModal") + c.HTML(http.StatusOK, "auto_pay_oob.html", h) +} + +func (h accountHandler) UpdateAutoPay(c *gin.Context) { + h.Reset(c) + + var autoPay domain.AutoPay + if err := persistence.DB.First(&autoPay, "id = ?", c.Param("auto_pay_id")).Error; err != nil { + c.HTML(http.StatusNotFound, "not-found", h) + return + } + + persistence.DB.Model(&autoPay).Select("Active").Updates(domain.AutoPay{Active: h.Form.Data["checked"] == "on"}) + persistence.DB.Find(&h.AutoPays, "account_id = ?", c.Param("id")) + h.accountService.FindByID(c.Param("id"), &h.Account) + + c.HTML(http.StatusOK, "auto_pay_oob.html", h) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 2705e78..25ea1f9 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -32,6 +32,7 @@ func Start() { "mul": func(a, b int) int { return a * b }, "mulfloat": func(a, b float64) float64 { return a * b }, "datetime": func(dateTime time.Time) string { return dateTime.Format("January 02, 2006 at 3:04 PM") }, + "date": func(date time.Time) string { return date.Format("January 02, 2006") }, }) // Load Templates router.LoadHTMLGlob("templates/**/*") @@ -155,7 +156,11 @@ func setupAccountRoutes() { PUT(":id/withdraw-or-deposit", middleware.AnyAuth(), account.WithdrawOrDeposit). GET(":id/send-money", middleware.AnyAuth(), account.OpenSendMoneyModal). PUT(":id/send-money", middleware.AnyAuth(), account.SendMoney). - GET(":id/statements", middleware.AnyAuth(), account.Statements) + GET(":id/statements", middleware.AnyAuth(), account.Statements). + POST(":id/auto-pay", middleware.UserAuth(), account.OpenAutoPayModal). + GET(":id/auto-pay", middleware.UserAuth(), account.AutoPay). + PUT(":id/auto-pay", middleware.UserAuth(), account.CreateAutoPay). + PATCH(":id/auto-pay/:auto_pay_id", middleware.UserAuth(), account.UpdateAutoPay) } func setupTransactionRoutes() { diff --git a/internal/domain/auto_pay.go b/internal/domain/auto_pay.go new file mode 100644 index 0000000..8b79b63 --- /dev/null +++ b/internal/domain/auto_pay.go @@ -0,0 +1,15 @@ +package domain + +import "time" + +type AutoPay struct { + Audit + Cadence string `gorm:"not null; default:day"` + StartDate time.Time `gorm:"not null"` + NextRunDate time.Time `gorm:"not null"` + Amount float64 `gorm:"not null; default:0.00; type:decimal(50,2)"` + Description string `gorm:"not null; size:255"` + AccountID int `gorm:"not null"` + Account Account `gorm:"foreignKey:AccountID; constraint:OnDelete:CASCADE"` + Active bool `gorm:"not null;default:true"` +} diff --git a/internal/infrastructure/persistence/database.go b/internal/infrastructure/persistence/database.go index a2a2746..b49b9ae 100644 --- a/internal/infrastructure/persistence/database.go +++ b/internal/infrastructure/persistence/database.go @@ -29,6 +29,7 @@ func RunMigrations() { DB.AutoMigrate(&domain.Transaction{}) DB.AutoMigrate(&domain.Announcement{}) DB.AutoMigrate(&domain.Subscription{}) + DB.AutoMigrate(&domain.AutoPay{}) log.Println("🟢 successfully ran the migrations") } diff --git a/internal/service/transaction_service.go b/internal/service/transaction_service.go index d30daa1..f6072c3 100644 --- a/internal/service/transaction_service.go +++ b/internal/service/transaction_service.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strconv" + "time" "github.com/bytebury/fun-banking/internal/domain" "github.com/bytebury/fun-banking/internal/infrastructure/persistence" @@ -21,6 +22,7 @@ type TransactionService interface { Update(id, userID, status string) error SendMoney(fromAccount domain.Account, recipient domain.Customer, transaction *domain.Transaction) error BulkTransfer(customerIDs []string, transaction *domain.Transaction) error + AutoPay(autoPay domain.AutoPay) error } type transactionService struct { @@ -35,6 +37,42 @@ func NewTransactionService() TransactionService { } } +func (ts transactionService) AutoPay(autoPay domain.AutoPay) error { + return persistence.DB.Transaction(func(tx *gorm.DB) error { + var account domain.Account + if err := persistence.DB. + Preload("Customer"). + Preload("Customer.Bank"). + First(&account, "id = ?", autoPay.AccountID).Error; err != nil { + return err + } + + transaction := domain.Transaction{ + AccountID: autoPay.AccountID, + Amount: autoPay.Amount, + Description: autoPay.Description, + UserID: &account.Customer.Bank.UserID, + } + + if err := ts.Create(&transaction); err != nil { + return err + } + + nextRunDate := time.Now() + + switch autoPay.Cadence { + case "day": + nextRunDate = nextRunDate.AddDate(0, 0, 1) + case "week": + nextRunDate = nextRunDate.AddDate(0, 0, 7) + case "month": + nextRunDate = nextRunDate.AddDate(0, 1, 0) + } + + return persistence.DB.Model(&autoPay).Select("NextRunDate").Updates(domain.AutoPay{NextRunDate: nextRunDate}).Error + }) +} + func (ts transactionService) Create(transaction *domain.Transaction) error { return persistence.DB.Transaction(func(tx *gorm.DB) error { defer func() { diff --git a/public/styles.css b/public/styles.css index fdb3778..8080648 100644 --- a/public/styles.css +++ b/public/styles.css @@ -143,6 +143,7 @@ textarea, input[type="text"], input[type="number"], input[type="password"], +input[type="date"], input[type="search"], input[type="email"] { border-width: 1px; @@ -151,6 +152,14 @@ input[type="email"] { width: 100%; } +input[type="date"] { + color: var(--default); +} + +:root[theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(1); +} + button[type="submit"] { font-weight: 900; color: white; diff --git a/templates/accounts/account.html b/templates/accounts/account.html index b1bc643..f2b55e7 100644 --- a/templates/accounts/account.html +++ b/templates/accounts/account.html @@ -66,6 +66,28 @@