Skip to content

Commit

Permalink
feature: auto pay for customers (#15)
Browse files Browse the repository at this point in the history
* feature: auto-pay wip

* set some jobs active

* auto pay now works

* fix icon
  • Loading branch information
Marcello authored Aug 23, 2024
1 parent 321df15 commit 1ca3075
Show file tree
Hide file tree
Showing 16 changed files with 432 additions and 1 deletion.
43 changes: 43 additions & 0 deletions cmd/fun-banking/main.go
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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
})
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions internal/api/handler/account_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
type accountHandler struct {
pageObject
Account domain.Account
AutoPays []domain.AutoPay
PagingInfo pagination.PagingInfo[domain.Transaction]
StatementPeriod string
LastTwelveMonths [][]string
Expand All @@ -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),
Expand Down
104 changes: 104 additions & 0 deletions internal/api/handler/auto_pay_handler.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 6 additions & 1 deletion internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*")
Expand Down Expand Up @@ -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() {
Expand Down
15 changes: 15 additions & 0 deletions internal/domain/auto_pay.go
Original file line number Diff line number Diff line change
@@ -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"`
}
1 change: 1 addition & 0 deletions internal/infrastructure/persistence/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
38 changes: 38 additions & 0 deletions internal/service/transaction_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strconv"
"time"

"github.com/bytebury/fun-banking/internal/domain"
"github.com/bytebury/fun-banking/internal/infrastructure/persistence"
Expand All @@ -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 {
Expand All @@ -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() {
Expand Down
9 changes: 9 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions templates/accounts/account.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ <h1 id="account-balance" class="font-extrabold text-2xl leading-1">
</a>
</li>
{{ if .SignedIn }}
<li>
<a
href="/accounts/{{ .Account.ID }}/auto-pay"
class="flex items-center gap-1 link-hover font-semibold"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
Auto Payments
</a>
</li>
<li>
<button
id="open-settings"
Expand Down
Loading

0 comments on commit 1ca3075

Please sign in to comment.