diff --git a/db/dev/v1_0.sql b/db/dev/v1_0.sql index 7121dad..7e2b1e3 100644 --- a/db/dev/v1_0.sql +++ b/db/dev/v1_0.sql @@ -37,3 +37,18 @@ CREATE TABLE IF NOT EXISTS "wspay" PRIMARY KEY (id), CONSTRAINT unique_id UNIQUE ("shopping_card_id") ); + + +CREATE TABLE IF NOT EXISTS "stripe" +( + "id" uuid NOT NULL, + "total_amount" int NOT NULL, + + "lang" varchar(128) DEFAULT '', + + "payment_intent_id" varchar(256) DEFAULT '', + + "payment_state" varchar(256) DEFAULT '', + + PRIMARY KEY (id) +); diff --git a/docker-compose-deploy.yml b/docker-compose-deploy.yml index e2066cf..bb1db37 100644 --- a/docker-compose-deploy.yml +++ b/docker-compose-deploy.yml @@ -2,7 +2,7 @@ version: '3.1' services: backend: - image: registry.bbr-dev.info/payment-poc/backend:latest + image: registry.s2internal.com/opgdirekt/payment-poc/backend:latest restart: on-failure depends_on: - database diff --git a/go.mod b/go.mod index c866999..686ac35 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( 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.0.8 // indirect + github.com/stripe/stripe-go/v72 v72.122.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect diff --git a/go.sum b/go.sum index 3d776a3..75fd7a4 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ 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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 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= @@ -68,6 +69,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stripe/stripe-go/v72 v72.122.0 h1:eRXWqnEwGny6dneQ5BsxGzUCED5n180u8n665JHlut8= +github.com/stripe/stripe-go/v72 v72.122.0/go.mod h1:QwqJQtduHubZht9mek5sds9CtQcKFdsykV9ZepRWwo0= 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.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= @@ -75,14 +78,19 @@ github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= @@ -92,6 +100,7 @@ google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cn google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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= diff --git a/main.go b/main.go index beac2c5..d4ac2cf 100644 --- a/main.go +++ b/main.go @@ -6,10 +6,14 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/joho/godotenv" + "github.com/stripe/stripe-go/v72" + "github.com/stripe/stripe-go/v72/checkout/session" "html/template" "log" "net/http" "payment-poc/migration" + "payment-poc/state" + stripe2 "payment-poc/stripe" "payment-poc/wspay" "strconv" "strings" @@ -27,8 +31,9 @@ func init() { godotenv.Load() BackendUrl = envMustExist("BACKEND_URL") - ShopId = envMustExist("SHOP_ID") - ShopSecret = envMustExist("SHOP_SECRET") + ShopId = envMustExist("WSPAY_SHOP_ID") + ShopSecret = envMustExist("WSPAY_SHOP_SECRET") + stripe.Key = envMustExist("STRIPE_KEY") log.SetPrefix("") log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) @@ -50,22 +55,22 @@ func main() { "formatCurrency": func(current int64) string { return fmt.Sprintf("%d,%02d", current/100, current%100) }, - "formatState": func(state wspay.PaymentState) string { - switch state { - case wspay.StateCanceled: + "formatState": func(stt state.PaymentState) string { + switch stt { + case state.StateCanceled: return "Otkazano" - case wspay.StateAccepted: + case state.StateAccepted: return "Prihvačeno" - case wspay.StateError: + case state.StateError: return "Greška" - case wspay.StateInitialized: + case state.StateInitialized: return "Inicijalna izrada" - case wspay.StateCanceledInitialization: + case state.StateCanceledInitialization: return "Otkazano tijekom izrade" - case wspay.StateCompleted: + case state.StateCompleted: return "Završeno" } - return "nepoznato stanje '" + string(state) + "'" + return "nepoznato stanje '" + string(stt) + "'" }, "omitempty": func(value string) string { if value == "" { @@ -85,15 +90,151 @@ func main() { wspayService := wspay.Service{ DB: client, } + stripeService := stripe2.Service{ + DB: client, + } g.LoadHTMLGlob("./templates/*.gohtml") g.GET("/", func(c *gin.Context) { - entries, err := wspayService.FetchAll() - log.Printf("%v", err) - c.HTML(200, "index.gohtml", gin.H{"Entries": entries}) + wspayEntries, _ := wspayService.FetchAll() + stripeEntries, _ := stripeService.FetchAll() + c.HTML(200, "index.gohtml", gin.H{"WsPay": wspayEntries, "Stripe": stripeEntries}) }) - g.GET("/initial", func(c *gin.Context) { + + g.GET("/methods", func(c *gin.Context) { + amount, err := strconv.ParseFloat(c.Query("amount"), 64) + if err != nil { + amount = 10.00 + } + + c.HTML(200, "methods.gohtml", gin.H{"Amount": amount}) + }) + + setupWsPayEndpoints(g.Group("wspay"), wspayService) + setupStripeEndpoints(g.Group("stripe"), stripeService) + + log.Fatal(http.ListenAndServe(":5281", g)) +} + +func getAccounts() gin.Accounts { + auth := strings.Split(envMustExist("AUTH"), ":") + return gin.Accounts{auth[0]: auth[1]} +} + +func parseDateTime(dateTime string) time.Time { + t, err := time.Parse("20060102150405", dateTime) + if err != nil { + log.Printf("couldn't parse response time %s: %v", dateTime, err) + } + return t +} + +func setupStripeEndpoints(g *gin.RouterGroup, stripeService stripe2.Service) { + g.GET("", func(c *gin.Context) { + amount, err := strconv.ParseFloat(c.Query("amount"), 64) + if err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + entry, err := stripeService.CreateEntry(int64(amount * 100)) + if err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + log.Printf("Created initial stripe entry (ammount=%d)", amount) + + currency := string(stripe.CurrencyEUR) + productName := "Example product" + productDescription := "Simple example product" + + params := &stripe.CheckoutSessionParams{ + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{ + Currency: ¤cy, + ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{ + Name: &productName, + Description: &productDescription, + }, + UnitAmount: &entry.TotalAmount, + }, + Quantity: stripe.Int64(1), + }, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + PaymentIntentData: &stripe.CheckoutSessionPaymentIntentDataParams{ + CaptureMethod: stripe.String("manual"), + }, + SuccessURL: stripe.String(BackendUrl + "/stripe/success?token=" + entry.Id.String()), + CancelURL: stripe.String(BackendUrl + "/stripe/cancel?token=" + entry.Id.String()), + } + result, err := session.New(params) + if err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + entry.PaymentIntentId = result.PaymentIntent.ID + stripeService.Update(entry) + + c.Redirect(http.StatusSeeOther, result.URL) + }) + + g.GET("success", func(c *gin.Context) { + id := uuid.MustParse(c.Query("token")) + + log.Printf("Received success response for stripe payment %s", id) + entry, err := stripeService.FetchById(id) + if err != nil { + log.Printf("Couldn't find payment info for stripe payment %s", id) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + entry.State = state.StateAccepted + + if err := stripeService.Update(entry); err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + log.Printf("Stripe payment %s received correctly, returning redirect", id) + c.Redirect(http.StatusTemporaryRedirect, "/") + }) + g.GET("error", func(c *gin.Context) { + id := uuid.MustParse(c.Query("token")) + + log.Printf("Received error response for stripe payment %s", id) + entry, err := stripeService.FetchById(id) + if err != nil { + log.Printf("Couldn't find payment info for stripe payment %s", id) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + entry.State = state.StateError + + if err := stripeService.Update(entry); err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + log.Printf("Stripe payment %s received correctly, returning redirect", id) + c.Redirect(http.StatusTemporaryRedirect, "/") + }) + g.GET("info/:id", func(c *gin.Context) { + id := uuid.MustParse(c.Param("id")) + entry, err := stripeService.FetchById(id) + if err != nil { + c.AbortWithError(http.StatusNotFound, err) + return + } + c.HTML(200, "stripe_info.gohtml", gin.H{"Entry": entry}) + }) +} + +func setupWsPayEndpoints(g *gin.RouterGroup, wspayService wspay.Service) { + g.GET("", func(c *gin.Context) { amount, err := strconv.ParseFloat(c.Query("amount"), 64) if err != nil { c.AbortWithError(http.StatusBadRequest, err) @@ -113,15 +254,15 @@ func main() { ShoppingCartID: entry.ShoppingCartID, Version: "2.0", TotalAmount: entry.TotalAmount, - ReturnURL: BackendUrl + "/initial/success", - ReturnErrorURL: BackendUrl + "/initial/error", - CancelURL: BackendUrl + "/initial/cancel", + ReturnURL: BackendUrl + "/wspay/success", + ReturnErrorURL: BackendUrl + "/wspay/error", + CancelURL: BackendUrl + "/wspay/cancel", Signature: wspay.CalculateFormSignature(ShopId, ShopSecret, entry.ShoppingCartID, entry.TotalAmount), } - c.HTML(200, "initial.gohtml", gin.H{"Action": wspay.AuthorisationForm, "Form": form}) + c.HTML(200, "wspay.gohtml", gin.H{"Action": wspay.AuthorisationForm, "Form": form}) }) - g.GET("/initial/success", func(c *gin.Context) { + g.GET("success", func(c *gin.Context) { response := wspay.WsPayFormReturn{} if err := c.ShouldBind(&response); err != nil { c.AbortWithError(http.StatusInternalServerError, err) @@ -161,7 +302,7 @@ func main() { entry.ApprovalCode = response.ApprovalCode entry.ErrorMessage = response.ErrorMessage - entry.State = wspay.StateAccepted + entry.State = state.StateAccepted if err := wspayService.Update(entry); err != nil { c.AbortWithError(http.StatusInternalServerError, err) @@ -176,10 +317,7 @@ func main() { c.Redirect(http.StatusTemporaryRedirect, "/") } }) - g.GET("/iframe", func(c *gin.Context) { - c.HTML(200, "iframe_handler.gohtml", gin.H{}) - }) - g.GET("/initial/error", func(c *gin.Context) { + g.GET("error", func(c *gin.Context) { response := wspay.WsPayFormError{} if err := c.ShouldBind(&response); err != nil { c.AbortWithError(http.StatusInternalServerError, err) @@ -212,7 +350,7 @@ func main() { entry.ErrorMessage = response.ErrorMessage entry.ErrorCodes = response.ErrorCodes - entry.State = wspay.StateError + entry.State = state.StateError if err := wspayService.Update(entry); err != nil { c.AbortWithError(http.StatusInternalServerError, err) @@ -234,9 +372,9 @@ func main() { c.AbortWithError(http.StatusNotFound, err) return } - c.HTML(200, "info.gohtml", gin.H{"Entry": entry}) + c.HTML(200, "wspay_info.gohtml", gin.H{"Entry": entry}) }) - g.GET("/initial/cancel", func(c *gin.Context) { + g.GET("cancel", func(c *gin.Context) { response := wspay.WsPayFormCancel{} if err := c.ShouldBind(&response); err != nil { c.AbortWithError(http.StatusInternalServerError, err) @@ -249,7 +387,7 @@ func main() { c.AbortWithError(http.StatusInternalServerError, err) return } - entry.State = wspay.StateCanceledInitialization + entry.State = state.StateCanceledInitialization if err := wspayService.Update(entry); err != nil { c.AbortWithError(http.StatusInternalServerError, err) @@ -264,19 +402,4 @@ func main() { c.Redirect(http.StatusTemporaryRedirect, "/") } }) - - log.Fatal(http.ListenAndServe(":5281", g)) -} - -func getAccounts() gin.Accounts { - auth := strings.Split(envMustExist("AUTH"), ":") - return gin.Accounts{auth[0]: auth[1]} -} - -func parseDateTime(dateTime string) time.Time { - t, err := time.Parse("20060102150405", dateTime) - if err != nil { - log.Printf("couldn't parse response time %s: %v", dateTime, err) - } - return t } diff --git a/makefile b/makefile index 8a790dc..c1f9d88 100644 --- a/makefile +++ b/makefile @@ -17,10 +17,10 @@ docker-dev: docker-prod: - docker image build -t registry.bbr-dev.info/payment-poc/backend:$(VERSION) . - docker tag registry.bbr-dev.info/payment-poc/backend:$(VERSION) registry.bbr-dev.info/payment-poc/backend:latest - docker image push registry.bbr-dev.info/payment-poc/backend:$(VERSION) - docker image push registry.bbr-dev.info/payment-poc/backend:latest + docker image build -t registry.s2internal.com/opgdirekt/payment-poc/backend:$(VERSION) . + docker tag registry.s2internal.com/opgdirekt/payment-poc/backend:$(VERSION) registry.s2internal.com/opgdirekt/payment-poc/backend:latest + docker image push registry.s2internal.com/opgdirekt/payment-poc/backend:$(VERSION) + docker image push registry.s2internal.com/opgdirekt/payment-poc/backend:latest release: git tag $(VERSION) diff --git a/state/model.go b/state/model.go new file mode 100644 index 0000000..6d5bd4c --- /dev/null +++ b/state/model.go @@ -0,0 +1,18 @@ +package state + +type PaymentState string + +const ( + // initial state + StateInitialized PaymentState = "initialized" + + // state on response + StateAccepted PaymentState = "accepted" + StateError PaymentState = "error" + StateCanceledInitialization PaymentState = "canceled_initialization" + + // state after confirmation + StateCompleted PaymentState = "completed" + StateVoided PaymentState = "voided" + StateCanceled PaymentState = "canceled" +) diff --git a/stripe/model.go b/stripe/model.go new file mode 100644 index 0000000..05fee09 --- /dev/null +++ b/stripe/model.go @@ -0,0 +1,16 @@ +package stripe + +import ( + "github.com/google/uuid" + "payment-poc/state" +) + +type StripeDb struct { + Id uuid.UUID `db:"id"` + TotalAmount int64 `db:"total_amount"` + Lang string `db:"lang"` + + PaymentIntentId string `db:"payment_intent_id"` + + State state.PaymentState `db:"payment_state"` +} diff --git a/stripe/service.go b/stripe/service.go new file mode 100644 index 0000000..9802afb --- /dev/null +++ b/stripe/service.go @@ -0,0 +1,46 @@ +package stripe + +import ( + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "payment-poc/state" +) + +type Service struct { + DB *sqlx.DB +} + +func (s *Service) CreateEntry(totalAmount int64) (StripeDb, error) { + id := uuid.Must(uuid.NewRandom()) + entry := StripeDb{ + Id: id, + TotalAmount: totalAmount, + State: state.StateInitialized, + } + _, err := s.DB.Exec(`INSERT INTO "stripe" ("id", "total_amount", "payment_state") VALUES ($1, $2, $3)`, + &entry.Id, &entry.TotalAmount, &entry.State, + ) + if err != nil { + return StripeDb{}, err + } + return s.FetchById(id) +} + +func (s *Service) FetchAll() ([]StripeDb, error) { + var entries []StripeDb + err := s.DB.Select(&entries, `SELECT * FROM "stripe"`) + return entries, err +} + +func (s *Service) FetchById(id uuid.UUID) (StripeDb, error) { + entry := StripeDb{} + err := s.DB.Get(&entry, `SELECT * FROM "stripe" WHERE "id" = $1`, id) + return entry, err +} + +func (s *Service) Update(entry StripeDb) error { + _, err := s.DB.Exec(`UPDATE "stripe" set "payment_intent_id" = $2, "payment_state" = $3 WHERE "id" = $1`, + &entry.Id, &entry.PaymentIntentId, &entry.State, + ) + return err +} diff --git a/templates/index.gohtml b/templates/index.gohtml index c99faa9..8795714 100644 --- a/templates/index.gohtml +++ b/templates/index.gohtml @@ -13,6 +13,9 @@ tr > td:nth-child(2) { text-align: right; } + tr > th:nth-child(2) { + text-align: right; + } td, th { padding: 0 8px; } @@ -24,7 +27,7 @@