Compare commits

..

3 Commits

Author SHA1 Message Date
Borna Rajković b4b0396b30 Added completion on wspay 2023-07-27 12:09:38 +02:00
Borna Rajković 9440fa9778 Added viva wallet support + completion on stripe 2023-07-27 10:11:40 +02:00
Borna Rajković cefc5314f2 Added support for stripe 2023-07-26 09:51:29 +02:00
21 changed files with 1178 additions and 79 deletions

View File

@ -37,3 +37,29 @@ CREATE TABLE IF NOT EXISTS "wspay"
PRIMARY KEY (id), PRIMARY KEY (id),
CONSTRAINT unique_id UNIQUE ("shopping_card_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)
);
CREATE TABLE IF NOT EXISTS "viva"
(
"id" uuid NOT NULL,
"order_id" varchar(24) DEFAULT '',
"transaction_id" uuid DEFAULT NULL,
"total_amount" int NOT NULL,
"event_id" varchar(128) DEFAULT '',
"eci" varchar(128) DEFAULT '',
"payment_state" varchar(256) DEFAULT '',
PRIMARY KEY (id)
);

View File

@ -2,7 +2,7 @@ version: '3.1'
services: services:
backend: backend:
image: registry.bbr-dev.info/payment-poc/backend:latest image: registry.s2internal.com/opgdirekt/payment-poc/backend:latest
restart: on-failure restart: on-failure
depends_on: depends_on:
- database - database

1
go.mod
View File

@ -26,6 +26,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect

9
go.sum
View File

@ -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.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.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.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.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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 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/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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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= 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.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 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 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 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= 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 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 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/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 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 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= 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= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

577
main.go
View File

@ -1,15 +1,25 @@
package main package main
import ( import (
"bytes"
"embed" "embed"
"encoding/json"
"errors"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/stripe/stripe-go/v72"
"github.com/stripe/stripe-go/v72/checkout/session"
"github.com/stripe/stripe-go/v72/paymentintent"
"html/template" "html/template"
"io"
"log" "log"
"net/http" "net/http"
"payment-poc/migration" "payment-poc/migration"
"payment-poc/state"
stripe2 "payment-poc/stripe"
"payment-poc/viva"
"payment-poc/wspay" "payment-poc/wspay"
"strconv" "strconv"
"strings" "strings"
@ -20,15 +30,30 @@ import (
var devMigrations embed.FS var devMigrations embed.FS
var BackendUrl string var BackendUrl string
var ShopId string var WsPayShopId string
var ShopSecret string var WsPayShopSecret string
var VivaMerchantId string
var VivaApiKey string
var VivaSourceCode string
var VivaClientId string
var VivaClientSecret string
func init() { func init() {
godotenv.Load() godotenv.Load()
BackendUrl = envMustExist("BACKEND_URL") BackendUrl = envMustExist("BACKEND_URL")
ShopId = envMustExist("SHOP_ID")
ShopSecret = envMustExist("SHOP_SECRET") WsPayShopId = envMustExist("WSPAY_SHOP_ID")
WsPayShopSecret = envMustExist("WSPAY_SHOP_SECRET")
VivaMerchantId = envMustExist("VIVA_WALLET_MERCHANT_ID")
VivaApiKey = envMustExist("VIVA_WALLET_API_KEY")
VivaSourceCode = envMustExist("VIVA_WALLET_SOURCE_CODE")
VivaClientId = envMustExist("VIVA_WALLET_CLIENT_ID")
VivaClientSecret = envMustExist("VIVA_WALLET_CLIENT_SECRET")
stripe.Key = envMustExist("STRIPE_KEY")
log.SetPrefix("") log.SetPrefix("")
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
@ -50,22 +75,27 @@ func main() {
"formatCurrency": func(current int64) string { "formatCurrency": func(current int64) string {
return fmt.Sprintf("%d,%02d", current/100, current%100) return fmt.Sprintf("%d,%02d", current/100, current%100)
}, },
"formatState": func(state wspay.PaymentState) string { "formatCurrency2": func(current int64) string {
switch state { return fmt.Sprintf("%d.%02d", current/100, current%100)
case wspay.StateCanceled: },
"formatState": func(stt state.PaymentState) string {
switch stt {
case state.StateCanceled:
return "Otkazano" return "Otkazano"
case wspay.StateAccepted: case state.StateVoided:
return "Otkazano sa strane administratora"
case state.StateAccepted:
return "Prihvačeno" return "Prihvačeno"
case wspay.StateError: case state.StateError:
return "Greška" return "Greška"
case wspay.StateInitialized: case state.StateInitialized:
return "Inicijalna izrada" return "Inicijalna izrada"
case wspay.StateCanceledInitialization: case state.StateCanceledInitialization:
return "Otkazano tijekom izrade" return "Otkazano tijekom izrade"
case wspay.StateCompleted: case state.StateCompleted:
return "Završeno" return "Završeno"
} }
return "nepoznato stanje '" + string(state) + "'" return "nepoznato stanje '" + string(stt) + "'"
}, },
"omitempty": func(value string) string { "omitempty": func(value string) string {
if value == "" { if value == "" {
@ -85,22 +115,369 @@ func main() {
wspayService := wspay.Service{ wspayService := wspay.Service{
DB: client, DB: client,
} }
stripeService := stripe2.Service{
DB: client,
}
vivaService := viva.Service{
DB: client,
ClientId: VivaClientId,
ClientSecret: VivaClientSecret,
SourceCode: VivaSourceCode,
MerchantId: VivaMerchantId,
ApiKey: VivaApiKey,
}
g.LoadHTMLGlob("./templates/*.gohtml") g.LoadHTMLGlob("./templates/*.gohtml")
g.GET("/", func(c *gin.Context) { g.GET("/", func(c *gin.Context) {
entries, err := wspayService.FetchAll() wspayEntries, _ := wspayService.FetchAll()
log.Printf("%v", err) stripeEntries, _ := stripeService.FetchAll()
c.HTML(200, "index.gohtml", gin.H{"Entries": entries}) vivaEntries, _ := vivaService.FetchAll()
c.HTML(200, "index.gohtml", gin.H{"WsPay": wspayEntries, "Stripe": stripeEntries, "Viva": vivaEntries})
}) })
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)
setupVivaEndpoints(g.Group("viva"), vivaService)
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 setupVivaEndpoints(g *gin.RouterGroup, vivaService viva.Service) {
g.GET("", func(c *gin.Context) {
amount, err := strconv.ParseFloat(c.Query("amount"), 64) amount, err := strconv.ParseFloat(c.Query("amount"), 64)
if err != nil { if err != nil {
c.AbortWithError(http.StatusBadRequest, err) c.AbortWithError(http.StatusBadRequest, err)
return return
} }
entry, err := wspayService.CreateEntry(ShopId, int64(amount*100)) entry, err := vivaService.CreateEntry(int64(amount * 100))
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
log.Printf("Created initial viva entry (ammount=%d)", amount)
entry, err = vivaService.CreatePaymentOrder(entry)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
vivaService.Update(entry)
c.Redirect(http.StatusSeeOther, "https://demo.vivapayments.com/web/checkout?ref="+entry.OrderId)
})
g.POST("complete/:id", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
amount, err := strconv.ParseFloat(c.PostForm("amount"), 64)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
entry, err := vivaService.FetchById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
if int64(amount*100) > entry.TotalAmount || int64(amount*100) < 1 {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if entry.State == state.StateInitialized || entry.State == state.StateAccepted {
entry, err = vivaService.CompleteTransaction(entry, int64(amount*100))
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
vivaService.Update(entry)
}
c.Redirect(http.StatusSeeOther, "/viva/info/"+id.String())
})
g.POST("cancel/:id", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
entry, err := vivaService.FetchById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
if entry.State == state.StateInitialized || entry.State == state.StateAccepted {
entry, err = vivaService.CancelTransaction(entry)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
vivaService.Update(entry)
}
c.Redirect(http.StatusSeeOther, "/viva/info/"+id.String())
})
g.GET("success", func(c *gin.Context) {
transactionId := uuid.MustParse(c.Query("t"))
orderId := viva.OrderId(c.Query("s"))
lang := c.Query("lang")
eventId := c.Query("eventId")
eci := c.Query("eci")
log.Printf("Received success response for viva payment %s", orderId)
entry, err := vivaService.FetchByOrderId(orderId)
if err != nil {
log.Printf("Couldn't find payment info for viva payment %s", orderId)
c.AbortWithError(http.StatusInternalServerError, err)
return
}
entry.State = state.StateAccepted
entry.ECI = eci
entry.Lang = lang
entry.EventId = eventId
entry.TransactionId = transactionId
if err := vivaService.Update(entry); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
log.Printf("Viva payment %s received correctly, returning redirect", entry.OrderId)
c.Redirect(http.StatusTemporaryRedirect, "/")
})
g.GET("error", func(c *gin.Context) {
transactionId := uuid.MustParse(c.Query("t"))
orderId := viva.OrderId(c.Query("s"))
lang := c.Query("lang")
eventId := c.Query("eventId")
eci := c.Query("eci")
log.Printf("Received error response for viva payment %s", orderId)
entry, err := vivaService.FetchByOrderId(orderId)
if err != nil {
log.Printf("Couldn't find payment info for viva payment %s", orderId)
c.AbortWithError(http.StatusInternalServerError, err)
return
}
entry.State = state.StateAccepted
entry.ECI = eci
entry.Lang = lang
entry.EventId = eventId
entry.TransactionId = transactionId
if err := vivaService.Update(entry); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
log.Printf("Viva payment %s received correctly, returning redirect", entry.OrderId)
c.Redirect(http.StatusTemporaryRedirect, "/")
})
g.GET("info/:id", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
entry, err := vivaService.FetchById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
c.HTML(200, "viva_info.gohtml", gin.H{"Entry": entry})
})
}
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: &currency,
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.POST("complete/:id", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
amount, err := strconv.ParseFloat(c.PostForm("amount"), 64)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
entry, err := stripeService.FetchById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
if int64(amount*100) > entry.TotalAmount || int64(amount*100) < 1 {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if entry.State == state.StateInitialized || entry.State == state.StateAccepted {
params := &stripe.PaymentIntentCaptureParams{
AmountToCapture: stripe.Int64(int64(amount * 100)),
}
pi, err := paymentintent.Capture(entry.PaymentIntentId, params)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
log.Printf("received state on completion: %v", pi.Status)
if pi.Status == stripe.PaymentIntentStatusSucceeded || pi.Status == stripe.PaymentIntentStatusProcessing {
entry.TotalAmount = pi.Amount
entry.State = state.StateCompleted
stripeService.Update(entry)
}
}
c.Redirect(http.StatusSeeOther, "/stripe/info/"+id.String())
})
g.POST("cancel/: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
}
if entry.State == state.StateInitialized || entry.State == state.StateAccepted {
params := &stripe.PaymentIntentCancelParams{}
pi, err := paymentintent.Cancel(entry.PaymentIntentId, params)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
log.Printf("received state on completion: %v", pi.Status)
if pi.Status == stripe.PaymentIntentStatusCanceled {
entry.State = state.StateCanceled
stripeService.Update(entry)
}
}
c.Redirect(http.StatusSeeOther, "/stripe/info/"+id.String())
})
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)
return
}
entry, err := wspayService.CreateEntry(WsPayShopId, int64(amount*100))
if err != nil { if err != nil {
c.AbortWithError(http.StatusBadRequest, err) c.AbortWithError(http.StatusBadRequest, err)
return return
@ -109,19 +486,137 @@ func main() {
log.Printf("Created initial wspay form (ammount=%d)", amount) log.Printf("Created initial wspay form (ammount=%d)", amount)
form := wspay.WsPayForm{ form := wspay.WsPayForm{
ShopID: ShopId, ShopID: WsPayShopId,
ShoppingCartID: entry.ShoppingCartID, ShoppingCartID: entry.ShoppingCartID,
Version: "2.0", Version: "2.0",
TotalAmount: entry.TotalAmount, TotalAmount: entry.TotalAmount,
ReturnURL: BackendUrl + "/initial/success", ReturnURL: BackendUrl + "/wspay/success",
ReturnErrorURL: BackendUrl + "/initial/error", ReturnErrorURL: BackendUrl + "/wspay/error",
CancelURL: BackendUrl + "/initial/cancel", CancelURL: BackendUrl + "/wspay/cancel",
Signature: wspay.CalculateFormSignature(ShopId, ShopSecret, entry.ShoppingCartID, entry.TotalAmount), Signature: wspay.CalculateFormSignature(WsPayShopId, WsPayShopSecret, 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.POST("complete/:id", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
amount, err := strconv.ParseFloat(c.PostForm("amount"), 64)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
entry, err := wspayService.FetchById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
if int64(amount*100) > entry.TotalAmount || int64(amount*100) < 1 {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if entry.State == state.StateAccepted {
var request = wspay.WsPayCompletionRequest{
Version: "2.0",
WsPayOrderId: entry.ShoppingCartID,
ShopId: entry.ShopID,
ApprovalCode: entry.ApprovalCode,
STAN: entry.STAN,
Amount: int64(amount * 100),
Signature: wspay.CalculateCompletionSignature(WsPayShopId, WsPayShopSecret, entry.ShoppingCartID, entry.STAN, entry.ApprovalCode, int64(amount*100)),
}
content, _ := json.Marshal(&request)
response, err := http.Post("https://test.wspay.biz/api/services/completion", "application/json", bytes.NewBuffer(content))
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if response.StatusCode == http.StatusOK {
transactionResponse := wspay.WsPayCompletionResponse{}
content, err := io.ReadAll(response.Body)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if err := json.Unmarshal(content, &transactionResponse); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
} else {
log.Printf("Received transaction response: success=%s, errorMessage=%s, approvalCode=%s",
transactionResponse.ActionSuccess, transactionResponse.ErrorMessage, transactionResponse.ApprovalCode,
)
if wspay.CompareCompletionReturnSignature(transactionResponse.Signature, WsPayShopId, WsPayShopSecret, entry.ShoppingCartID, entry.STAN, transactionResponse.ActionSuccess, transactionResponse.ApprovalCode) != nil {
entry.TotalAmount = int64(amount * 100)
entry.State = state.StateCompleted
wspayService.Update(entry)
} else {
c.AbortWithError(http.StatusInternalServerError, errors.New("received invalid signature"))
return
}
}
} else {
c.AbortWithError(http.StatusInternalServerError, errors.New("received wrong status, expected 200 received "+strconv.FormatInt(int64(response.StatusCode), 10)))
return
}
}
c.Redirect(http.StatusSeeOther, "/wspay/info/"+id.String())
})
g.POST("cancel/:id", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
entry, err := wspayService.FetchById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
if entry.State == state.StateAccepted {
var request = wspay.WsPayCompletionRequest{
Version: "2.0",
WsPayOrderId: entry.ShoppingCartID,
ShopId: entry.ShopID,
ApprovalCode: entry.ApprovalCode,
STAN: entry.STAN,
Amount: entry.TotalAmount,
Signature: wspay.CalculateCompletionSignature(WsPayShopId, WsPayShopSecret, entry.ShoppingCartID, entry.STAN, entry.ApprovalCode, entry.TotalAmount),
}
content, _ := json.Marshal(&request)
response, err := http.Post("https://test.wspay.biz/api/services/void", "application/json", bytes.NewBuffer(content))
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if response.StatusCode == http.StatusOK {
transactionResponse := wspay.WsPayCompletionResponse{}
content, err := io.ReadAll(response.Body)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if err := json.Unmarshal(content, &transactionResponse); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
} else {
log.Printf("Received transaction response: success=%s, errorMessage=%s, approvalCode=%s",
transactionResponse.ActionSuccess, transactionResponse.ErrorMessage, transactionResponse.ApprovalCode,
)
if wspay.CompareCompletionReturnSignature(transactionResponse.Signature, WsPayShopId, WsPayShopSecret, entry.ShoppingCartID, entry.STAN, transactionResponse.ActionSuccess, transactionResponse.ApprovalCode) != nil {
entry.State = state.StateCanceled
wspayService.Update(entry)
} else {
c.AbortWithError(http.StatusInternalServerError, errors.New("received invalid signature"))
return
}
}
} else {
c.AbortWithError(http.StatusInternalServerError, errors.New("received wrong status, expected 200 received "+strconv.FormatInt(int64(response.StatusCode), 10)))
return
}
}
c.Redirect(http.StatusSeeOther, "/wspay/info/"+id.String())
})
g.GET("success", func(c *gin.Context) {
response := wspay.WsPayFormReturn{} response := wspay.WsPayFormReturn{}
if err := c.ShouldBind(&response); err != nil { if err := c.ShouldBind(&response); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
@ -135,7 +630,7 @@ func main() {
return return
} }
if err := wspay.CompareFormReturnSignature(response.Signature, ShopId, ShopSecret, response.ShoppingCartID, response.Success, response.ApprovalCode); err != nil { if err := wspay.CompareFormReturnSignature(response.Signature, WsPayShopId, WsPayShopSecret, response.ShoppingCartID, response.Success, response.ApprovalCode); err != nil {
log.Printf("Invalid signature for transaction %s", response.ShoppingCartID) log.Printf("Invalid signature for transaction %s", response.ShoppingCartID)
c.AbortWithError(http.StatusBadRequest, err) c.AbortWithError(http.StatusBadRequest, err)
return return
@ -161,7 +656,7 @@ func main() {
entry.ApprovalCode = response.ApprovalCode entry.ApprovalCode = response.ApprovalCode
entry.ErrorMessage = response.ErrorMessage entry.ErrorMessage = response.ErrorMessage
entry.State = wspay.StateAccepted entry.State = state.StateAccepted
if err := wspayService.Update(entry); err != nil { if err := wspayService.Update(entry); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
@ -176,10 +671,7 @@ func main() {
c.Redirect(http.StatusTemporaryRedirect, "/") c.Redirect(http.StatusTemporaryRedirect, "/")
} }
}) })
g.GET("/iframe", func(c *gin.Context) { g.GET("error", func(c *gin.Context) {
c.HTML(200, "iframe_handler.gohtml", gin.H{})
})
g.GET("/initial/error", func(c *gin.Context) {
response := wspay.WsPayFormError{} response := wspay.WsPayFormError{}
if err := c.ShouldBind(&response); err != nil { if err := c.ShouldBind(&response); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
@ -212,7 +704,7 @@ func main() {
entry.ErrorMessage = response.ErrorMessage entry.ErrorMessage = response.ErrorMessage
entry.ErrorCodes = response.ErrorCodes entry.ErrorCodes = response.ErrorCodes
entry.State = wspay.StateError entry.State = state.StateError
if err := wspayService.Update(entry); err != nil { if err := wspayService.Update(entry); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
@ -234,9 +726,9 @@ func main() {
c.AbortWithError(http.StatusNotFound, err) c.AbortWithError(http.StatusNotFound, err)
return 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{} response := wspay.WsPayFormCancel{}
if err := c.ShouldBind(&response); err != nil { if err := c.ShouldBind(&response); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
@ -249,7 +741,7 @@ func main() {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
entry.State = wspay.StateCanceledInitialization entry.State = state.StateCanceledInitialization
if err := wspayService.Update(entry); err != nil { if err := wspayService.Update(entry); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
@ -264,19 +756,4 @@ func main() {
c.Redirect(http.StatusTemporaryRedirect, "/") 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
} }

View File

@ -17,10 +17,10 @@ docker-dev:
docker-prod: docker-prod:
docker image build -t registry.bbr-dev.info/payment-poc/backend:$(VERSION) . docker image build -t registry.s2internal.com/opgdirekt/payment-poc/backend:$(VERSION) .
docker tag registry.bbr-dev.info/payment-poc/backend:$(VERSION) registry.bbr-dev.info/payment-poc/backend:latest docker tag registry.s2internal.com/opgdirekt/payment-poc/backend:$(VERSION) registry.s2internal.com/opgdirekt/payment-poc/backend:latest
docker image push registry.bbr-dev.info/payment-poc/backend:$(VERSION) docker image push registry.s2internal.com/opgdirekt/payment-poc/backend:$(VERSION)
docker image push registry.bbr-dev.info/payment-poc/backend:latest docker image push registry.s2internal.com/opgdirekt/payment-poc/backend:latest
release: release:
git tag $(VERSION) git tag $(VERSION)

18
state/model.go Normal file
View File

@ -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"
)

16
stripe/model.go Normal file
View File

@ -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"`
}

46
stripe/service.go Normal file
View File

@ -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
}

View File

@ -13,6 +13,9 @@
tr > td:nth-child(2) { tr > td:nth-child(2) {
text-align: right; text-align: right;
} }
tr > th:nth-child(2) {
text-align: right;
}
td, th { td, th {
padding: 0 8px; padding: 0 8px;
} }
@ -24,7 +27,7 @@
<body class="container"> <body class="container">
<h2>Novo plačanje</h2> <h2>Novo plačanje</h2>
<form method="get" action="/initial"> <form method="get" action="/methods">
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="amount">Vrijednost</label> <label class="form-label" for="amount">Vrijednost</label>
<input class="form-control" id="amount" required name="amount" type="number" step="0.01" min="0"> <input class="form-control" id="amount" required name="amount" type="number" step="0.01" min="0">
@ -33,7 +36,7 @@
</form> </form>
<div> <div>
<h2>Plačanja</h2> <h2>WsPay</h2>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -43,9 +46,49 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range .Entries}} {{range .WsPay}}
<tr> <tr>
<td><a class="link-primary" href="/info/{{.Id}}">{{.Id}}</a></td> <td><a class="link-primary" href="/wspay/info/{{.Id}}">{{.Id}}</a></td>
<td>{{formatCurrency .TotalAmount}}</td>
<td>{{formatState .State}}</td>
</tr>
{{end}}
</tbody>
</table>
<h2>Stripe</h2>
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>Vrijednost</th>
<th>Stanje</th>
</tr>
</thead>
<tbody>
{{range .Stripe}}
<tr>
<td><a class="link-primary" href="/stripe/info/{{.Id}}">{{.Id}}</a></td>
<td>{{formatCurrency .TotalAmount}}</td>
<td>{{formatState .State}}</td>
</tr>
{{end}}
</tbody>
</table>
<h2>Viva</h2>
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>Vrijednost</th>
<th>Stanje</th>
</tr>
</thead>
<tbody>
{{range .Viva}}
<tr>
<td><a class="link-primary" href="/viva/info/{{.Id}}">{{.Id}}</a></td>
<td>{{formatCurrency .TotalAmount}}</td> <td>{{formatCurrency .TotalAmount}}</td>
<td>{{formatState .State}}</td> <td>{{formatState .State}}</td>
</tr> </tr>

30
templates/methods.gohtml Normal file
View File

@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Index</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<style>
th {text-align: left}
tr > td:nth-child(2) {
text-align: right;
}
td, th {
padding: 0 8px;
}
h2 {
margin-top: 16px;
}
</style>
</head>
<body class="container">
<h2>Izaberi metodu plačanja</h2>
<a class="btn btn-success" href="/wspay?amount={{.Amount}}">WsPay</a>
<a class="btn btn-success" href="/stripe?amount={{.Amount}}">Stripe</a>
<a class="btn btn-success" href="/viva?amount={{.Amount}}">Viva</a>
</body>
</html>

View File

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Info</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<style>
th {text-align: left}
h2 {
margin-top: 16px;
}
</style>
</head>
<body class="container">
<h2>Plačanje {{.Entry.Id}}</h2>
<table class="table">
<tr><th>Id: </th><td>{{.Entry.Id}}</td></tr>
<tr><th>Ukupna vrijednost: </th><td>{{formatCurrency .Entry.TotalAmount}}</td></tr>
<tr><th>Jezik: </th><td>{{omitempty .Entry.Lang}}</td></tr>
<tr><th>Stanje: </th><td>{{formatState .Entry.State}}</td></tr>
</table>
{{if eq .Entry.State "accepted"}}
<form class="mb-3" method="post" action="/stripe/complete/{{.Entry.Id}}">
<div class="mb-3">
<label class="form-label" for="amount">Završi transakciju</label>
<input class="form-control" id="amount" required name="amount" type="number" value="{{formatCurrency2 .Entry.TotalAmount}}" step="0.01" min="0.01" max="{{formatCurrency2 .Entry.TotalAmount}}">
</div>
<button class="btn btn-primary" type="submit">Završi transakciju</button>
</form>
<form method="post" action="/stripe/cancel/{{.Entry.Id}}">
<button class="btn btn-primary" type="submit">Otkaži transakciju</button>
</form>
{{end}}
</body>
</html>

View File

@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Info</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<style>
th {text-align: left}
h2 {
margin-top: 16px;
}
</style>
</head>
<body class="container">
<h2>Plačanje {{.Entry.Id}}</h2>
<table class="table">
<tr><th>Id: </th><td>{{.Entry.Id}}</td></tr>
<tr><th>Order id: </th><td>{{.Entry.OrderId}}</td></tr>
<tr><th>Transaction id: </th><td>{{.Entry.TransactionId.String}}</td></tr>
<tr><th>Ukupna vrijednost: </th><td>{{formatCurrency .Entry.TotalAmount}}</td></tr>
<tr><th>Jezik: </th><td>{{omitempty .Entry.Lang}}</td></tr>
<tr><th>Događaj: </th><td>{{.Entry.EventId}}</td></tr>
<tr><th>Stanje: </th><td>{{formatState .Entry.State}}</td></tr>
</table>
{{if eq .Entry.State "accepted"}}
<form class="mb-3" method="post" action="/viva/complete/{{.Entry.Id}}">
<div class="mb-3">
<label class="form-label" for="amount">Završi transakciju</label>
<input class="form-control" id="amount" required name="amount" type="number" value="{{formatCurrency2 .Entry.TotalAmount}}" step="0.01" min="0.01" max="{{formatCurrency2 .Entry.TotalAmount}}">
</div>
<button class="btn btn-primary" type="submit">Završi transakciju</button>
</form>
<form method="post" action="/viva/cancel/{{.Entry.Id}}">
<button class="btn btn-primary" type="submit">Otkaži transakciju</button>
</form>
{{end}}
</body>
</html>

View File

@ -45,5 +45,18 @@
<tr><th>Stanje: </th><td>{{formatState .Entry.State}}</td></tr> <tr><th>Stanje: </th><td>{{formatState .Entry.State}}</td></tr>
</table> </table>
{{if eq .Entry.State "accepted"}}
<form class="mb-3" method="post" action="/wspay/complete/{{.Entry.Id}}">
<div class="mb-3">
<label class="form-label" for="amount">Završi transakciju</label>
<input class="form-control" id="amount" required name="amount" type="number" value="{{formatCurrency2 .Entry.TotalAmount}}" step="0.01" min="0.01" max="{{formatCurrency2 .Entry.TotalAmount}}">
</div>
<button class="btn btn-primary" type="submit">Završi transakciju</button>
</form>
<form method="post" action="/wspay/cancel/{{.Entry.Id}}">
<button class="btn btn-primary" type="submit">Otkaži transakciju</button>
</form>
{{end}}
</body> </body>
</html> </html>

26
viva/model.go Normal file
View File

@ -0,0 +1,26 @@
package viva
import (
"github.com/google/uuid"
"payment-poc/state"
"time"
)
const VivaUrl = "https://demo-api.vivapayments.com"
type VivaDb struct {
Id uuid.UUID `db:"id"`
OrderId string `db:"order_id"`
TransactionId uuid.UUID `db:"transaction_id"`
TotalAmount int64 `db:"total_amount"`
Lang string `db:"lang"`
EventId string `db:"event_id"`
ECI string `db:"eci"`
DateTime time.Time `db:"date_time"`
// transaction response
State state.PaymentState `db:"payment_state"`
}

235
viva/service.go Normal file
View File

@ -0,0 +1,235 @@
package viva
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"io"
"log"
"net/http"
"net/url"
"payment-poc/state"
"strconv"
"strings"
"time"
)
type Service struct {
DB *sqlx.DB
Token string
Expiration time.Time
ClientId string
ClientSecret string
SourceCode string
MerchantId string
ApiKey string
}
func (s *Service) OAuthToken() (string, error) {
if s.Token != "" && s.Expiration.After(time.Now()) {
return s.Token, nil
}
return s.fetchOAuthToken()
}
func (s *Service) CreatePaymentOrder(entry VivaDb) (VivaDb, error) {
token, err := s.OAuthToken()
if err != nil {
return VivaDb{}, err
}
orderRequest := VivaOrderRequest{
Amount: entry.TotalAmount,
Description: "Example payment",
MerchantDescription: "Example payment",
PreAuth: true,
AllowRecurring: false,
Source: s.SourceCode,
}
content, err := json.Marshal(&orderRequest)
if err != nil {
return VivaDb{}, err
}
request, err := http.NewRequest("POST", "https://demo-api.vivapayments.com/checkout/v2/orders", bytes.NewReader(content))
request.Header.Add("authorization", "Bearer "+token)
request.Header.Add("content-type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
return VivaDb{}, err
}
if response.StatusCode == http.StatusOK {
orderResponse := VivaOrderResponse{}
content, err := io.ReadAll(response.Body)
if err != nil {
return VivaDb{}, err
}
if err := json.Unmarshal(content, &orderResponse); err != nil {
return VivaDb{}, err
} else {
entry.OrderId = string(orderResponse.OrderId)
return entry, nil
}
} else {
return VivaDb{}, errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(response.StatusCode), 10))
}
}
func (s *Service) CreateEntry(totalAmount int64) (VivaDb, error) {
id := uuid.Must(uuid.NewRandom())
entry := VivaDb{
Id: id,
TotalAmount: totalAmount,
State: state.StateInitialized,
}
_, err := s.DB.Exec(`INSERT INTO "viva" ("id", "total_amount", "payment_state") VALUES ($1, $2, $3)`,
&entry.Id, &entry.TotalAmount, &entry.State,
)
if err != nil {
return VivaDb{}, err
}
return s.FetchById(id)
}
func (s *Service) FetchAll() ([]VivaDb, error) {
var entries []VivaDb
err := s.DB.Select(&entries, `SELECT * FROM "viva"`)
return entries, err
}
func (s *Service) FetchById(id uuid.UUID) (VivaDb, error) {
entry := VivaDb{}
err := s.DB.Get(&entry, `SELECT * FROM "viva" WHERE "id" = $1`, id)
return entry, err
}
func (s *Service) FetchByOrderId(id OrderId) (VivaDb, error) {
entry := VivaDb{}
err := s.DB.Get(&entry, `SELECT * FROM "viva" WHERE "order_id" = $1`, string(id))
return entry, err
}
func (s *Service) Update(entry VivaDb) error {
_, err := s.DB.Exec(`UPDATE "viva" set "order_id" = $2, "transaction_id" = $3, "payment_state" = $4 WHERE "id" = $1`,
&entry.Id, &entry.OrderId, &entry.TransactionId, &entry.State,
)
return err
}
func (s *Service) fetchOAuthToken() (string, error) {
form := url.Values{
"grant_type": []string{"client_credentials"},
}
request, err := http.NewRequest("POST", "https://demo-accounts.vivapayments.com/connect/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
request.Header.Add("content-type", "application/x-www-form-urlencoded")
request.SetBasicAuth(s.ClientId, s.ClientSecret)
response, err := http.DefaultClient.Do(request)
if err != nil {
return "", err
}
if response.StatusCode == http.StatusOK {
oauthObject := VivaOAuthResponse{}
content, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
if err := json.Unmarshal(content, &oauthObject); err != nil {
return "", err
} else {
s.Token = oauthObject.AccessToken
s.Expiration = time.Now().Add(time.Duration(oauthObject.ExpiresIn) * time.Second)
}
} else {
return "", errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(response.StatusCode), 10))
}
return s.Token, nil
}
func (s *Service) CompleteTransaction(entry VivaDb, amount int64) (VivaDb, error) {
completionRequest := VivaTransactionCompleteRequest{
Amount: amount,
CustomerDescription: "Example transaction",
}
content, err := json.Marshal(&completionRequest)
if err != nil {
return VivaDb{}, err
}
request, err := http.NewRequest("POST", "https://demo.vivapayments.com/api/transactions/"+entry.TransactionId.String(), bytes.NewReader(content))
request.Header.Add("authorization", "Bearer "+s.BasicAuth())
request.Header.Add("content-type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
return VivaDb{}, err
}
if response.StatusCode == http.StatusOK {
transactionResponse := VivaTransactionResponse{}
content, err := io.ReadAll(response.Body)
if err != nil {
return VivaDb{}, err
}
if err := json.Unmarshal(content, &transactionResponse); err != nil {
return VivaDb{}, err
} else {
log.Printf("Received transaction response: success=%v, eventId=%d, status=%s, amount=%f, errorCode=%d, errorText=%s",
transactionResponse.Success, transactionResponse.EventId, transactionResponse.StatusId, transactionResponse.Amount, transactionResponse.ErrorCode, transactionResponse.ErrorText,
)
if transactionResponse.StatusId == "F" {
entry.TotalAmount = int64(transactionResponse.Amount * 100)
entry.State = state.StateCompleted
} else {
return VivaDb{}, errors.New("received invalid status = " + transactionResponse.StatusId)
}
}
} else {
return VivaDb{}, errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(response.StatusCode), 10))
}
return entry, nil
}
func (s *Service) BasicAuth() string {
return base64.StdEncoding.EncodeToString([]byte(s.MerchantId + ":" + s.ApiKey))
}
func (s *Service) CancelTransaction(entry VivaDb) (VivaDb, error) {
request, err := http.NewRequest("DELETE", "https://demo.vivapayments.com/api/transactions/"+entry.TransactionId.String()+"?amount="+strconv.FormatInt(entry.TotalAmount, 10), bytes.NewReader([]byte{}))
request.Header.Add("authorization", "Bearer "+s.BasicAuth())
response, err := http.DefaultClient.Do(request)
if err != nil {
return VivaDb{}, err
}
if response.StatusCode == http.StatusOK {
transactionResponse := VivaTransactionResponse{}
content, err := io.ReadAll(response.Body)
if err != nil {
return VivaDb{}, err
}
if err := json.Unmarshal(content, &transactionResponse); err != nil {
return VivaDb{}, err
} else {
log.Printf("Received transaction response: success=%v, eventId=%d, status=%s, amount=%f, errorCode=%d, errorText=%s",
transactionResponse.Success, transactionResponse.EventId, transactionResponse.StatusId, transactionResponse.Amount, transactionResponse.ErrorCode, transactionResponse.ErrorText,
)
if transactionResponse.StatusId == "F" {
entry.State = state.StateVoided
} else {
return VivaDb{}, errors.New("received invalid status = " + transactionResponse.StatusId)
}
}
} else {
return VivaDb{}, errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(response.StatusCode), 10))
}
return entry, nil
}

44
viva/viva.go Normal file
View File

@ -0,0 +1,44 @@
package viva
type OrderId string
func (o OrderId) MarshalJSON() ([]byte, error) {
return []byte(o), nil
}
func (o *OrderId) UnmarshalJSON(value []byte) error {
*o = OrderId(value)
return nil
}
type VivaOrderRequest struct {
Amount int64 `json:"amount"`
Description string `json:"customerTrns"`
MerchantDescription string `json:"merchantTrns"`
PreAuth bool `json:"preauth"`
AllowRecurring bool `json:"allowRecurring"`
Source string `json:"sourceCode"`
}
type VivaOrderResponse struct {
OrderId OrderId `json:"orderCode"`
}
type VivaOAuthResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
type VivaTransactionCompleteRequest struct {
Amount int64 `json:"amount"`
CustomerDescription string `json:"customerTrns"`
}
type VivaTransactionResponse struct {
Amount float64 `json:"Amount"`
StatusId string `json:"StatusId"`
ErrorCode int64 `json:"ErrorCode"`
ErrorText string `json:"ErrorText"`
EventId int64 `json:"EventId"`
Success bool `json:"Success"`
}

View File

@ -2,26 +2,10 @@ package wspay
import ( import (
"github.com/google/uuid" "github.com/google/uuid"
"payment-poc/state"
"time" "time"
) )
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"
)
type WsPayDb struct { type WsPayDb struct {
Id uuid.UUID `db:"id"` Id uuid.UUID `db:"id"`
ShopID string `db:"shop_id"` ShopID string `db:"shop_id"`
@ -53,5 +37,5 @@ type WsPayDb struct {
ErrorMessage string `db:"error_message"` ErrorMessage string `db:"error_message"`
ErrorCodes string `db:"error_codes"` ErrorCodes string `db:"error_codes"`
State PaymentState `db:"payment_state"` State state.PaymentState `db:"payment_state"`
} }

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"payment-poc/state"
"strconv" "strconv"
) )
@ -20,7 +21,7 @@ func (s *Service) CreateEntry(shopId string, totalAmount int64) (WsPayDb, error)
ShopID: shopId, ShopID: shopId,
ShoppingCartID: id.String(), ShoppingCartID: id.String(),
TotalAmount: totalAmount, TotalAmount: totalAmount,
State: StateInitialized, State: state.StateInitialized,
} }
_, err := s.DB.Exec(`INSERT INTO "wspay" ("id", "shop_id", "shopping_card_id", "total_amount", "payment_state") VALUES ($1, $2, $3, $4, $5)`, _, err := s.DB.Exec(`INSERT INTO "wspay" ("id", "shop_id", "shopping_card_id", "total_amount", "payment_state") VALUES ($1, $2, $3, $4, $5)`,
&entry.Id, &entry.ShopID, &entry.ShoppingCartID, &entry.TotalAmount, &entry.State, &entry.Id, &entry.ShopID, &entry.ShoppingCartID, &entry.TotalAmount, &entry.State,
@ -73,6 +74,27 @@ func CalculateFormSignature(shopId string, secret string, cartId string, amount
return hex.EncodeToString(hash.Sum(nil)) return hex.EncodeToString(hash.Sum(nil))
} }
func CalculateCompletionSignature(shopId string, secret string, cartId string, stan string, approvalCode string, amount int64) string {
/**
Represents a signature created from string formatted from following values in a following order using
SHA512 algorithm:
ShopID
WsPayOrderId
SecretKey
STAN
SecretKey
ApprovalCode
SecretKey
Amount
SecretKey
WsPayOrderId
*/
signature := shopId + cartId + secret + stan + secret + approvalCode + secret + strconv.FormatInt(amount, 10) + secret + cartId
hash := sha512.New()
hash.Write([]byte(signature))
return hex.EncodeToString(hash.Sum(nil))
}
func CompareFormReturnSignature(signature string, shopId string, secret string, cartId string, success int, approvalCode string) error { func CompareFormReturnSignature(signature string, shopId string, secret string, cartId string, success int, approvalCode string) error {
/** /**
Represents a signature created from string formatted from following values in a following order using Represents a signature created from string formatted from following values in a following order using
@ -96,3 +118,26 @@ func CompareFormReturnSignature(signature string, shopId string, secret string,
return errors.New("signature mismatch") return errors.New("signature mismatch")
} }
} }
func CompareCompletionReturnSignature(signature string, shopId string, secret string, stan string, actionSuccess string, approvalCode string, cartId string) error {
/**
Represents a signature created from string formatted from following values in a following order using
SHA512 algorithm:
ShopID
SecretKey
STAN
ActionSuccess
SecretKey
ApprovalCode
WsPayOrderId
Merchant should validate this signature to make sure that the request is originating from WSPayForm.
*/
calculatedSignature := shopId + secret + stan + actionSuccess + secret + approvalCode + cartId
hash := sha512.New()
hash.Write([]byte(calculatedSignature))
if hex.EncodeToString(hash.Sum(nil)) == signature {
return nil
} else {
return errors.New("signature mismatch")
}
}

View File

@ -102,7 +102,7 @@ type WsPayCompletionRequest struct {
ShopId string ShopId string
ApprovalCode string ApprovalCode string
STAN string STAN string
Amount string Amount int64
Signature string Signature string
} }