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),
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:
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

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/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

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.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=

577
main.go
View File

@ -1,15 +1,25 @@
package main
import (
"bytes"
"embed"
"encoding/json"
"errors"
"fmt"
"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"
"github.com/stripe/stripe-go/v72/paymentintent"
"html/template"
"io"
"log"
"net/http"
"payment-poc/migration"
"payment-poc/state"
stripe2 "payment-poc/stripe"
"payment-poc/viva"
"payment-poc/wspay"
"strconv"
"strings"
@ -20,15 +30,30 @@ import (
var devMigrations embed.FS
var BackendUrl string
var ShopId string
var ShopSecret string
var WsPayShopId string
var WsPayShopSecret string
var VivaMerchantId string
var VivaApiKey string
var VivaSourceCode string
var VivaClientId string
var VivaClientSecret string
func init() {
godotenv.Load()
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.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
@ -50,22 +75,27 @@ 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:
"formatCurrency2": func(current int64) string {
return fmt.Sprintf("%d.%02d", current/100, current%100)
},
"formatState": func(stt state.PaymentState) string {
switch stt {
case state.StateCanceled:
return "Otkazano"
case wspay.StateAccepted:
case state.StateVoided:
return "Otkazano sa strane administratora"
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,22 +115,369 @@ func main() {
wspayService := wspay.Service{
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.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()
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)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
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 {
c.AbortWithError(http.StatusBadRequest, err)
return
@ -109,19 +486,137 @@ func main() {
log.Printf("Created initial wspay form (ammount=%d)", amount)
form := wspay.WsPayForm{
ShopID: ShopId,
ShopID: WsPayShopId,
ShoppingCartID: entry.ShoppingCartID,
Version: "2.0",
TotalAmount: entry.TotalAmount,
ReturnURL: BackendUrl + "/initial/success",
ReturnErrorURL: BackendUrl + "/initial/error",
CancelURL: BackendUrl + "/initial/cancel",
Signature: wspay.CalculateFormSignature(ShopId, ShopSecret, entry.ShoppingCartID, entry.TotalAmount),
ReturnURL: BackendUrl + "/wspay/success",
ReturnErrorURL: BackendUrl + "/wspay/error",
CancelURL: BackendUrl + "/wspay/cancel",
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{}
if err := c.ShouldBind(&response); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
@ -135,7 +630,7 @@ func main() {
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)
c.AbortWithError(http.StatusBadRequest, err)
return
@ -161,7 +656,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 +671,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 +704,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 +726,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 +741,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 +756,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
}

View File

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

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) {
text-align: right;
}
tr > th:nth-child(2) {
text-align: right;
}
td, th {
padding: 0 8px;
}
@ -24,7 +27,7 @@
<body class="container">
<h2>Novo plačanje</h2>
<form method="get" action="/initial">
<form method="get" action="/methods">
<div class="mb-3">
<label class="form-label" for="amount">Vrijednost</label>
<input class="form-control" id="amount" required name="amount" type="number" step="0.01" min="0">
@ -33,7 +36,7 @@
</form>
<div>
<h2>Plačanja</h2>
<h2>WsPay</h2>
<table class="table">
<thead>
<tr>
@ -43,9 +46,49 @@
</tr>
</thead>
<tbody>
{{range .Entries}}
{{range .WsPay}}
<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>{{formatState .State}}</td>
</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>
</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>
</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 (
"github.com/google/uuid"
"payment-poc/state"
"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 {
Id uuid.UUID `db:"id"`
ShopID string `db:"shop_id"`
@ -53,5 +37,5 @@ type WsPayDb struct {
ErrorMessage string `db:"error_message"`
ErrorCodes string `db:"error_codes"`
State PaymentState `db:"payment_state"`
State state.PaymentState `db:"payment_state"`
}

View File

@ -6,6 +6,7 @@ import (
"errors"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"payment-poc/state"
"strconv"
)
@ -20,7 +21,7 @@ func (s *Service) CreateEntry(shopId string, totalAmount int64) (WsPayDb, error)
ShopID: shopId,
ShoppingCartID: id.String(),
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)`,
&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))
}
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 {
/**
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")
}
}
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
ApprovalCode string
STAN string
Amount string
Amount int64
Signature string
}