Added payment update action

This commit is contained in:
Borna Rajković 2023-07-31 09:21:54 +02:00
parent b6203d8a03
commit fe6f3b6672
12 changed files with 420 additions and 105 deletions

161
main.go
View File

@ -14,11 +14,11 @@ import (
"os" "os"
"payment-poc/database" "payment-poc/database"
"payment-poc/migration" "payment-poc/migration"
"payment-poc/mock"
"payment-poc/state" "payment-poc/state"
stripe2 "payment-poc/stripe" stripe2 "payment-poc/stripe"
"payment-poc/viva" "payment-poc/viva"
"payment-poc/wspay" "payment-poc/wspay"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -28,9 +28,11 @@ import (
var devMigrations embed.FS var devMigrations embed.FS
type PaymentProvider interface { type PaymentProvider interface {
CreatePaymentUrl(amount int64) (string, error) CreatePaymentUrl(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, url string, err error)
CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error)
CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error) CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error)
UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error)
} }
func init() { func init() {
@ -56,10 +58,11 @@ func main() {
} }
g.SetFuncMap(template.FuncMap{ g.SetFuncMap(template.FuncMap{
"formatCurrency": formatCurrency, "formatCurrency": formatCurrency,
"decimalCurrency": decimalCurrency, "formatCurrencyPtr": formatCurrencyPtr,
"formatState": formatState, "decimalCurrency": decimalCurrency,
"omitempty": omitempty, "formatState": formatState,
"omitempty": omitempty,
}) })
g.NoRoute(func(c *gin.Context) { g.NoRoute(func(c *gin.Context) {
@ -72,40 +75,45 @@ func main() {
backendUrl := envMustExist("BACKEND_URL") backendUrl := envMustExist("BACKEND_URL")
paymentGateways := map[state.PaymentGateway]PaymentProvider{} paymentGateways := map[state.PaymentGateway]PaymentProvider{}
entryProvider := database.PaymentEntryProvider{DB: client} entryProvider := &database.PaymentEntryProvider{DB: client}
g.LoadHTMLGlob("./templates/*.gohtml") g.LoadHTMLGlob("./templates/*.gohtml")
if hasProfile(string(state.GatewayMock)) {
mockService := mock.Service{
BackendUrl: backendUrl,
}
mockHandlers(g.Group("mock"), entryProvider, &mockService)
paymentGateways[state.GatewayMock] = &mockService
}
if hasProfile(string(state.GatewayWsPay)) { if hasProfile(string(state.GatewayWsPay)) {
wspayService := wspay.Service{ wspayService := wspay.Service{
Provider: &entryProvider,
ShopId: envMustExist("WSPAY_SHOP_ID"), ShopId: envMustExist("WSPAY_SHOP_ID"),
ShopSecret: envMustExist("WSPAY_SHOP_SECRET"), ShopSecret: envMustExist("WSPAY_SHOP_SECRET"),
BackendUrl: backendUrl, BackendUrl: backendUrl,
} }
setupWsPayEndpoints(g.Group("wspay"), &wspayService) wsPayHandlers(g.Group("wspay"), entryProvider, &wspayService)
paymentGateways[state.GatewayWsPay] = &wspayService paymentGateways[state.GatewayWsPay] = &wspayService
} }
if hasProfile(string(state.GatewayStripe)) { if hasProfile(string(state.GatewayStripe)) {
stripeService := stripe2.Service{ stripeService := stripe2.Service{
Provider: &entryProvider,
ApiKey: envMustExist("STRIPE_KEY"), ApiKey: envMustExist("STRIPE_KEY"),
BackendUrl: backendUrl, BackendUrl: backendUrl,
} }
setupStripeEndpoints(g.Group("stripe"), &stripeService) stripeHandlers(g.Group("stripe"), entryProvider, &stripeService)
paymentGateways[state.GatewayStripe] = &stripeService paymentGateways[state.GatewayStripe] = &stripeService
stripe.Key = envMustExist("STRIPE_KEY") stripe.Key = envMustExist("STRIPE_KEY")
} }
if hasProfile(string(state.GatewayVivaWallet)) { if hasProfile(string(state.GatewayVivaWallet)) {
vivaService := viva.Service{ vivaService := viva.Service{
Provider: &entryProvider,
ClientId: envMustExist("VIVA_WALLET_CLIENT_ID"), ClientId: envMustExist("VIVA_WALLET_CLIENT_ID"),
ClientSecret: envMustExist("VIVA_WALLET_CLIENT_SECRET"), ClientSecret: envMustExist("VIVA_WALLET_CLIENT_SECRET"),
SourceCode: envMustExist("VIVA_WALLET_SOURCE_CODE"), SourceCode: envMustExist("VIVA_WALLET_SOURCE_CODE"),
MerchantId: envMustExist("VIVA_WALLET_MERCHANT_ID"), MerchantId: envMustExist("VIVA_WALLET_MERCHANT_ID"),
ApiKey: envMustExist("VIVA_WALLET_API_KEY"), ApiKey: envMustExist("VIVA_WALLET_API_KEY"),
} }
setupVivaEndpoints(g.Group("viva"), &vivaService) vivaHandlers(g.Group("viva"), entryProvider, &vivaService)
paymentGateways[state.GatewayVivaWallet] = &vivaService paymentGateways[state.GatewayVivaWallet] = &vivaService
} }
@ -118,16 +126,9 @@ func main() {
if err != nil { if err != nil {
amount = 10.00 amount = 10.00
} }
var gateways []state.PaymentGateway c.HTML(200, "methods.gohtml", gin.H{"Amount": amount, "Gateways": mapGateways(paymentGateways)})
for key := range paymentGateways {
gateways = append(gateways, key)
}
sort.Slice(gateways, func(i, j int) bool {
return string(gateways[i]) < string(gateways[j])
})
c.HTML(200, "methods.gohtml", gin.H{"Amount": amount, "Gateways": gateways})
}) })
g.GET("/:gateway", func(c *gin.Context) { g.GET("/methods/:gateway", func(c *gin.Context) {
gateway, err := fetchGateway(c.Param("gateway")) gateway, err := fetchGateway(c.Param("gateway"))
if err != nil { if err != nil {
c.AbortWithError(http.StatusBadRequest, err) c.AbortWithError(http.StatusBadRequest, err)
@ -139,7 +140,13 @@ func main() {
c.AbortWithError(http.StatusBadRequest, err) c.AbortWithError(http.StatusBadRequest, err)
return return
} }
if url, err := paymentGateway.CreatePaymentUrl(amount); err == nil { entry, err := entryProvider.CreateEntry(database.PaymentEntry{
Gateway: gateway,
State: state.StatePreinitialized,
TotalAmount: amount,
})
if entry, url, err := paymentGateway.CreatePaymentUrl(entry); err == nil {
entryProvider.UpdateEntry(entry)
c.Redirect(http.StatusSeeOther, url) c.Redirect(http.StatusSeeOther, url)
} else { } else {
c.AbortWithError(http.StatusBadRequest, err) c.AbortWithError(http.StatusBadRequest, err)
@ -210,10 +217,81 @@ func main() {
} }
} }
}) })
g.POST("/entries/:id/refresh", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
entry, err := entryProvider.FetchById(id)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if paymentGateway, ok := paymentGateways[entry.Gateway]; ok {
entry, err = paymentGateway.UpdatePayment(entry)
if err == nil {
entryProvider.UpdateEntry(entry)
}
c.Redirect(http.StatusSeeOther, "/entries/"+id.String())
} else {
if err != nil {
c.AbortWithError(http.StatusInternalServerError, errors.New("payment gateway not supported: "+string(entry.Gateway)))
return
}
}
})
log.Fatal(http.ListenAndServe(":5281", g)) log.Fatal(http.ListenAndServe(":5281", g))
} }
func mockHandlers(g *gin.RouterGroup, provider *database.PaymentEntryProvider, mockService *mock.Service) {
g.GET("/gateway/:id", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
entry, err := provider.FetchById(id)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
c.HTML(http.StatusOK, "mock_gateway.gohtml", gin.H{"Entry": entry})
})
g.GET("success", func(c *gin.Context) {
url, err := mockService.HandleResponse(c, provider, state.StateAccepted)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
})
g.GET("error", func(c *gin.Context) {
url, err := mockService.HandleResponse(c, provider, state.StateError)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
})
}
func mapGateways(gateways map[state.PaymentGateway]PaymentProvider) map[string]string {
providerMap := map[string]string{}
for key := range gateways {
providerMap[string(key)] = mapGatewayName(key)
}
return providerMap
}
func mapGatewayName(key state.PaymentGateway) string {
switch key {
case state.GatewayStripe:
return "Stripe"
case state.GatewayVivaWallet:
return "Viva wallet"
case state.GatewayWsPay:
return "WsPay"
case state.GatewayMock:
return "mock"
}
return ""
}
func fetchGateway(gateway string) (state.PaymentGateway, error) { func fetchGateway(gateway string) (state.PaymentGateway, error) {
switch gateway { switch gateway {
case string(state.GatewayWsPay): case string(state.GatewayWsPay):
@ -222,6 +300,8 @@ func fetchGateway(gateway string) (state.PaymentGateway, error) {
return state.GatewayStripe, nil return state.GatewayStripe, nil
case string(state.GatewayVivaWallet): case string(state.GatewayVivaWallet):
return state.GatewayVivaWallet, nil return state.GatewayVivaWallet, nil
case string(state.GatewayMock):
return state.GatewayMock, nil
} }
return "", errors.New("unknown gateway: " + gateway) return "", errors.New("unknown gateway: " + gateway)
} }
@ -239,9 +319,9 @@ func fetchAmount(amount string) (int64, error) {
} }
} }
func setupVivaEndpoints(g *gin.RouterGroup, vivaService *viva.Service) { func vivaHandlers(g *gin.RouterGroup, provider *database.PaymentEntryProvider, vivaService *viva.Service) {
g.GET("success", func(c *gin.Context) { g.GET("success", func(c *gin.Context) {
url, err := vivaService.HandleResponse(c, state.StateAccepted) url, err := vivaService.HandleResponse(c, provider, state.StateAccepted)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
@ -249,7 +329,7 @@ func setupVivaEndpoints(g *gin.RouterGroup, vivaService *viva.Service) {
c.Redirect(http.StatusSeeOther, url) c.Redirect(http.StatusSeeOther, url)
}) })
g.GET("error", func(c *gin.Context) { g.GET("error", func(c *gin.Context) {
url, err := vivaService.HandleResponse(c, state.StateError) url, err := vivaService.HandleResponse(c, provider, state.StateError)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
@ -258,10 +338,10 @@ func setupVivaEndpoints(g *gin.RouterGroup, vivaService *viva.Service) {
}) })
} }
func setupStripeEndpoints(g *gin.RouterGroup, stripeService *stripe2.Service) { func stripeHandlers(g *gin.RouterGroup, provider *database.PaymentEntryProvider, stripeService *stripe2.Service) {
g.GET("success", func(c *gin.Context) { g.GET("success", func(c *gin.Context) {
url, err := stripeService.HandleResponse(c, state.StateAccepted) url, err := stripeService.HandleResponse(c, provider, state.StateAccepted)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
@ -269,7 +349,7 @@ func setupStripeEndpoints(g *gin.RouterGroup, stripeService *stripe2.Service) {
c.Redirect(http.StatusSeeOther, url) c.Redirect(http.StatusSeeOther, url)
}) })
g.GET("error", func(c *gin.Context) { g.GET("error", func(c *gin.Context) {
url, err := stripeService.HandleResponse(c, state.StateError) url, err := stripeService.HandleResponse(c, provider, state.StateError)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
@ -278,25 +358,24 @@ func setupStripeEndpoints(g *gin.RouterGroup, stripeService *stripe2.Service) {
}) })
} }
func setupWsPayEndpoints(g *gin.RouterGroup, wspayService *wspay.Service) { func wsPayHandlers(g *gin.RouterGroup, provider *database.PaymentEntryProvider, wspayService *wspay.Service) {
g.GET("/initialize/:id", func(c *gin.Context) { g.GET("/initialize/:id", func(c *gin.Context) {
entry, err := wspayService.Provider.FetchById(uuid.MustParse(c.Param("id"))) entry, err := provider.FetchById(uuid.MustParse(c.Param("id")))
if err != nil { if err != nil {
c.AbortWithError(http.StatusNotFound, err) c.AbortWithError(http.StatusNotFound, err)
return return
} }
if entry.State != state.StateInitialized { if entry.State != state.StatePreinitialized {
c.AbortWithError(http.StatusBadRequest, err) c.AbortWithError(http.StatusBadRequest, err)
return return
} }
form := wspayService.InitializePayment(entry) form := wspayService.InitializePayment(entry)
c.HTML(200, "wspay.gohtml", gin.H{"Action": wspay.AuthorisationForm, "Form": form}) c.HTML(200, "wspay.gohtml", gin.H{"Action": wspay.AuthorisationForm, "Form": form})
}) })
g.GET("success", func(c *gin.Context) { g.GET("success", func(c *gin.Context) {
url, err := wspayService.HandleSuccessResponse(c) url, err := wspayService.HandleSuccessResponse(c, provider)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
@ -304,7 +383,7 @@ func setupWsPayEndpoints(g *gin.RouterGroup, wspayService *wspay.Service) {
c.Redirect(http.StatusSeeOther, url) c.Redirect(http.StatusSeeOther, url)
}) })
g.GET("error", func(c *gin.Context) { g.GET("error", func(c *gin.Context) {
url, err := wspayService.HandleErrorResponse(c, state.StateError) url, err := wspayService.HandleErrorResponse(c, provider, state.StateError)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
@ -312,7 +391,7 @@ func setupWsPayEndpoints(g *gin.RouterGroup, wspayService *wspay.Service) {
c.Redirect(http.StatusSeeOther, url) c.Redirect(http.StatusSeeOther, url)
}) })
g.GET("cancel", func(c *gin.Context) { g.GET("cancel", func(c *gin.Context) {
url, err := wspayService.HandleErrorResponse(c, state.StateCanceled) url, err := wspayService.HandleErrorResponse(c, provider, state.StateCanceled)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
@ -336,7 +415,7 @@ func formatState(stt state.PaymentState) string {
case state.StateCanceled: case state.StateCanceled:
return "Otkazana" return "Otkazana"
case state.StateVoided: case state.StateVoided:
return "Otkazana sa strane administratora" return "Poništena"
case state.StateAccepted: case state.StateAccepted:
return "Predautorizirana" return "Predautorizirana"
case state.StateError: case state.StateError:
@ -357,6 +436,14 @@ func formatCurrency(current int64) string {
return fmt.Sprintf("%d,%02d", current/100, current%100) return fmt.Sprintf("%d,%02d", current/100, current%100)
} }
func formatCurrencyPtr(current *int64) string {
if current != nil {
return fmt.Sprintf("%d,%02d", (*current)/100, (*current)%100)
} else {
return "-"
}
}
func decimalCurrency(current int64) string { func decimalCurrency(current int64) string {
return fmt.Sprintf("%d,%02d", current/100, current%100) return fmt.Sprintf("%d,%02d", current/100, current%100)
} }

View File

@ -8,6 +8,9 @@ const (
// initial state // initial state
StateInitialized PaymentState = "initialized" StateInitialized PaymentState = "initialized"
// state given to async payments (eg. GooglePay,ApplePay...)
StatePending PaymentState = "pending"
// state on response // state on response
StateAccepted PaymentState = "accepted" StateAccepted PaymentState = "accepted"
StateError PaymentState = "error" StateError PaymentState = "error"
@ -25,4 +28,5 @@ const (
GatewayWsPay PaymentGateway = "wspay" GatewayWsPay PaymentGateway = "wspay"
GatewayStripe PaymentGateway = "stripe" GatewayStripe PaymentGateway = "stripe"
GatewayVivaWallet PaymentGateway = "viva-wallet" GatewayVivaWallet PaymentGateway = "viva-wallet"
GatewayMock PaymentGateway = "mock"
) )

View File

@ -12,29 +12,53 @@ import (
) )
type Service struct { type Service struct {
Provider *database.PaymentEntryProvider
ApiKey string ApiKey string
BackendUrl string BackendUrl string
} }
func (s *Service) CreatePaymentUrl(amount int64) (url string, err error) { func (s *Service) UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error) {
entry, err := s.Provider.CreateEntry(database.PaymentEntry{ pi, err := paymentintent.Get(*entry.PaymentIntentId, nil)
Gateway: state.GatewayStripe,
State: state.StateInitialized,
TotalAmount: amount,
})
if err != nil { if err != nil {
return "", err return entry, err
} }
entry, url, err = s.InitializePayment(entry) newState := determineState(pi.Status)
if entry.State != newState && newState != "" {
log.Printf("Updated state for %s: %s -> %s", entry.Id.String(), entry.State, newState)
if pi.AmountReceived > 0 {
entry.Amount = &pi.AmountReceived
}
entry.State = newState
}
return entry, nil
}
func determineState(status stripe.PaymentIntentStatus) state.PaymentState {
switch status {
case stripe.PaymentIntentStatusCanceled:
return state.StateCanceled
case stripe.PaymentIntentStatusProcessing:
return state.StatePending
case stripe.PaymentIntentStatusRequiresAction:
return state.StatePending
case stripe.PaymentIntentStatusRequiresCapture:
return state.StateAccepted
case stripe.PaymentIntentStatusRequiresConfirmation:
return state.StatePending
case stripe.PaymentIntentStatusRequiresPaymentMethod:
return state.StateVoided
case stripe.PaymentIntentStatusSucceeded:
return state.StateCompleted
}
return ""
}
func (s *Service) CreatePaymentUrl(entry database.PaymentEntry) (database.PaymentEntry, string, error) {
entry, url, err := s.InitializePayment(entry)
if err != nil { if err != nil {
return "", err return entry, "", err
} }
entry, err = s.Provider.UpdateEntry(entry) return entry, url, nil
if err != nil {
return "", err
}
return url, nil
} }
func (s *Service) InitializePayment(entry database.PaymentEntry) (database.PaymentEntry, string, error) { func (s *Service) InitializePayment(entry database.PaymentEntry) (database.PaymentEntry, string, error) {
@ -68,6 +92,7 @@ func (s *Service) InitializePayment(entry database.PaymentEntry) (database.Payme
if err != nil { if err != nil {
return database.PaymentEntry{}, "", err return database.PaymentEntry{}, "", err
} }
entry.State = state.StateInitialized
entry.PaymentIntentId = &result.PaymentIntent.ID entry.PaymentIntentId = &result.PaymentIntent.ID
return entry, result.URL, nil return entry, result.URL, nil
@ -82,9 +107,10 @@ func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64)
return database.PaymentEntry{}, err return database.PaymentEntry{}, err
} }
log.Printf("received state on completion: %v", pi.Status) log.Printf("received state on completion: %v", pi.Status)
if pi.Status == stripe.PaymentIntentStatusSucceeded || pi.Status == stripe.PaymentIntentStatusProcessing { newState := determineState(pi.Status)
entry.State = newState
if newState == state.StateCompleted || newState == state.StatePending {
entry.Amount = &pi.AmountReceived entry.Amount = &pi.AmountReceived
entry.State = state.StateCompleted
} }
return entry, nil return entry, nil
} }
@ -102,13 +128,13 @@ func (s *Service) CancelTransaction(entry database.PaymentEntry) (database.Payme
return entry, nil return entry, nil
} }
func (s *Service) HandleResponse(c *gin.Context, paymentState state.PaymentState) (string, error) { func (s *Service) HandleResponse(c *gin.Context, provider *database.PaymentEntryProvider, paymentState state.PaymentState) (string, error) {
id := uuid.MustParse(c.Query("token")) id := uuid.MustParse(c.Query("token"))
entry, err := s.Provider.FetchById(id) entry, err := provider.FetchById(id)
if err != nil { if err != nil {
return "", err return "", err
} }
entry.State = paymentState entry.State = paymentState
s.Provider.UpdateEntry(entry) provider.UpdateEntry(entry)
return "/entries/" + entry.Id.String(), nil return "/entries/" + entry.Id.String(), nil
} }

View File

@ -24,7 +24,15 @@
} }
</style> </style>
</head> </head>
<body class="container"> <body>
<!-- As a link -->
<nav class="navbar navbar-dark bg-dark">
<section class="container">
<a class="navbar-brand" href="/">Payment-poc</a>
</section>
</nav>
<section class="container">
<h2>Novo plaćanje</h2> <h2>Novo plaćanje</h2>
<form method="get" action="/methods"> <form method="get" action="/methods">
@ -58,5 +66,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
</body> </body>
</html> </html>

View File

@ -15,16 +15,30 @@
} }
</style> </style>
</head> </head>
<body class="container"> <body>
<!-- As a link -->
<nav class="navbar navbar-dark bg-dark">
<section class="container">
<a class="navbar-brand" href="/">Payment-poc</a>
</section>
</nav>
<section class="container">
<h2>Plaćanje {{.Entry.Id}}</h2> <h2>Plaćanje {{.Entry.Id}}</h2>
{{if not (eq .Entry.State "preinitialized")}}
<form method="post" action="/entries/{{.Entry.Id}}/refresh">
<button class="btn btn-primary">Ažuriraj</button>
</form>
{{end}}
<table class="table"> <table class="table">
<tr><th>Id: </th><td>{{.Entry.Id}}</td></tr> <tr><th>Id: </th><td>{{.Entry.Id}}</td></tr>
<tr><th>Datum izrade: </th><td>{{.Entry.Created.Format "Jan 02, 2006 15:04:05 UTC"}}</td></tr> <tr><th>Datum izrade: </th><td>{{.Entry.Created.Format "Jan 02, 2006 15:04:05 UTC"}}</td></tr>
<tr><th>Zadnja izmjena: </th><td>{{or (.Entry.Modified.Format "Jan 02, 2006 15:04:05 UTC") .Entry.Created.Format "Jan 02, 2006 15:04:05 UTC"}}</td></tr> <tr><th>Zadnja izmjena: </th><td>{{or (.Entry.Modified.Format "Jan 02, 2006 15:04:05 UTC") .Entry.Created.Format "Jan 02, 2006 15:04:05 UTC"}}</td></tr>
<tr><th>Gateway: </th><td>{{.Entry.Gateway}}</td></tr> <tr><th>Gateway: </th><td>{{.Entry.Gateway}}</td></tr>
<tr><th>Naplaćena vrijednost: </th><td>{{or .Entry.Amount "-"}}</td></tr> <tr><th>Naplaćena vrijednost: </th><td>{{formatCurrencyPtr .Entry.Amount}}</td></tr>
<tr><th>Ukupna vrijednost: </th><td>{{formatCurrency .Entry.TotalAmount}}</td></tr> <tr><th>Ukupna vrijednost: </th><td>{{formatCurrency .Entry.TotalAmount}}</td></tr>
<tr><th>Jezik: </th><td>{{or .Entry.Lang "-"}}</td></tr> <tr><th>Jezik: </th><td>{{or .Entry.Lang "-"}}</td></tr>
<tr><th>Greške: </th><td>{{or .Entry.Error "-"}}</td></tr> <tr><th>Greške: </th><td>{{or .Entry.Error "-"}}</td></tr>
@ -32,7 +46,6 @@
{{if eq .Entry.Gateway "wspay"}} {{if eq .Entry.Gateway "wspay"}}
<tr><th>WsPay</th><td></td></tr> <tr><th>WsPay</th><td></td></tr>
<tr><th>Shopping cart ID: </th><td>{{or .Entry.ShoppingCartID "-"}}</td></tr>
<tr><th>Success: </th><td>{{or .Entry.Success "-"}}</td></tr> <tr><th>Success: </th><td>{{or .Entry.Success "-"}}</td></tr>
{{end}} {{end}}
@ -61,5 +74,6 @@
<button class="btn btn-primary" type="submit">Otkaži plaćanje</button> <button class="btn btn-primary" type="submit">Otkaži plaćanje</button>
</form> </form>
{{end}} {{end}}
</section>
</body> </body>
</html> </html>

View File

@ -21,10 +21,19 @@
} }
</style> </style>
</head> </head>
<body class="container"> <body>
<!-- As a link -->
<nav class="navbar navbar-dark bg-dark">
<section class="container">
<a class="navbar-brand" href="/">Payment-poc</a>
</section>
</nav>
<section class="container">
<h2>Izaberi metodu plaćanja</h2> <h2>Izaberi metodu plaćanja</h2>
{{range .Gateways}} {{ range $key, $value := .Gateways }}
<a class="btn btn-success" href="/{{.}}?amount={{$.Amount}}">{{.}}</a> <a class="btn btn-success" href="/methods/{{$key}}?amount={{$.Amount}}">{{$value}}</a>
{{end}} {{end}}
</section>
</body> </body>
</html> </html>

View File

@ -0,0 +1,27 @@
<!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>
</head>
<body>
<!-- As a link -->
<nav class="navbar navbar-dark bg-dark">
<section class="container">
<a class="navbar-brand" href="/">Payment-poc</a>
</section>
</nav>
<section class="container">
<h2>Mock gateway {{.Entry.Id.String}}</h2>
<p>{{formatCurrency .Entry.TotalAmount}}</p>
<a href="/mock/success?id={{.Entry.Id.String}}" class="btn btn-success">Potvrdi plaćanje</a>
<a href="/mock/error?id={{.Entry.Id.String}}" class="btn btn-danger">Otkaži plaćanje</a>
</section>
</body>
</html>

View File

@ -5,7 +5,7 @@
<meta name="viewport" <meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> 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"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Izradi plančanje</title> <title>Izradi planćanje</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous"> <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> <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> <style>
@ -15,7 +15,7 @@
</style> </style>
</head> </head>
<body class="container" style="margin-top: 32px"> <body class="container" style="margin-top: 32px">
<h2>Započni proces plačanja</h2> <h2>Započni proces plaćanja</h2>
<form id="wspay-form" action="{{.Action}}" method="POST"> <form id="wspay-form" action="{{.Action}}" method="POST">
<input type="hidden" name="ShopID" value="{{.Form.ShopID}}"> <input type="hidden" name="ShopID" value="{{.Form.ShopID}}">

View File

@ -18,7 +18,6 @@ import (
) )
type Service struct { type Service struct {
Provider *database.PaymentEntryProvider
ClientId string ClientId string
ClientSecret string ClientSecret string
SourceCode string SourceCode string
@ -30,24 +29,57 @@ type Service struct {
expiration time.Time expiration time.Time
} }
func (s *Service) CreatePaymentUrl(amount int64) (url string, err error) { func (s *Service) UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error) {
entry, err := s.Provider.CreateEntry(database.PaymentEntry{ token, err := s.oAuthToken()
Gateway: state.GatewayVivaWallet, httpResponse, err := createRequest(
State: state.StateInitialized, "GET",
TotalAmount: amount, "https://demo-api.vivapayments.com/checkout/v2/transactions/"+entry.TransactionId.String(),
}) map[string]string{"authorization": "Bearer " + token, "content-type": "application/json"},
[]byte{},
)
if err != nil { if err != nil {
return "", err return database.PaymentEntry{}, err
} }
entry, err = s.InitializePayment(entry)
var response TransactionStatusResponse
err = readResponse(httpResponse, &response)
if err != nil { if err != nil {
return "", err return database.PaymentEntry{}, err
} }
entry, err = s.Provider.UpdateEntry(entry) newState := determineStatus(response.StatusId)
if entry.State != newState && newState != "" {
log.Printf("Updated state for %s: %s -> %s", entry.Id.String(), entry.State, newState)
entry.State = newState
}
return entry, nil
}
func determineStatus(id TransactionStatus) state.PaymentState {
switch id {
case PaymentPreauthorized:
return state.StateAccepted
case PaymentPending:
return state.StatePending
case PaymentSuccessful:
return state.StateCompleted
case PaymentUnsuccessful:
return state.StateError
case PaymentRefunded:
return state.StateVoided
case PaymentVoided:
return state.StateVoided
}
log.Printf("Unknonw transactionStatus: %s", string(id))
return ""
}
func (s *Service) CreatePaymentUrl(entry database.PaymentEntry) (database.PaymentEntry, string, error) {
entry, err := s.InitializePayment(entry)
if err != nil { if err != nil {
return "", err return entry, "", err
} }
return "https://demo.vivapayments.com/web/checkout?ref=" + string(*entry.OrderId), nil return entry, "https://demo.vivapayments.com/web/checkout?ref=" + string(*entry.OrderId), nil
} }
func (s *Service) InitializePayment(entry database.PaymentEntry) (database.PaymentEntry, error) { func (s *Service) InitializePayment(entry database.PaymentEntry) (database.PaymentEntry, error) {
@ -80,6 +112,7 @@ func (s *Service) InitializePayment(entry database.PaymentEntry) (database.Payme
if err != nil { if err != nil {
return database.PaymentEntry{}, err return database.PaymentEntry{}, err
} }
entry.State = state.StateInitialized
entry.OrderId = &response.OrderId entry.OrderId = &response.OrderId
return entry, nil return entry, nil
} }
@ -208,7 +241,7 @@ func (s *Service) basicAuth() string {
return base64.StdEncoding.EncodeToString([]byte(s.MerchantId + ":" + s.ApiKey)) return base64.StdEncoding.EncodeToString([]byte(s.MerchantId + ":" + s.ApiKey))
} }
func (s *Service) HandleResponse(c *gin.Context, expectedState state.PaymentState) (string, error) { func (s *Service) HandleResponse(c *gin.Context, provider *database.PaymentEntryProvider, state state.PaymentState) (string, error) {
transactionId := uuid.MustParse(c.Query("t")) transactionId := uuid.MustParse(c.Query("t"))
orderId := database.OrderId(c.Query("s")) orderId := database.OrderId(c.Query("s"))
lang := c.Query("lang") lang := c.Query("lang")
@ -216,19 +249,19 @@ func (s *Service) HandleResponse(c *gin.Context, expectedState state.PaymentStat
eci := c.Query("eci") eci := c.Query("eci")
log.Printf("Received error response for viva payment %s", orderId) log.Printf("Received error response for viva payment %s", orderId)
entry, err := s.Provider.FetchByOrderId(orderId) entry, err := provider.FetchByOrderId(orderId)
if err != nil { if err != nil {
log.Printf("Couldn't find payment info for viva payment %s", orderId) log.Printf("Couldn't find payment info for viva payment %s", orderId)
return "", err return "", err
} }
entry.State = expectedState entry.State = state
entry.ECI = &eci entry.ECI = &eci
entry.Lang = &lang entry.Lang = &lang
entry.EventId = &eventId entry.EventId = &eventId
entry.TransactionId = &transactionId entry.TransactionId = &transactionId
if _, err := s.Provider.UpdateEntry(entry); err != nil { if _, err := provider.UpdateEntry(entry); err != nil {
return "", err return "", err
} }

View File

@ -33,3 +33,23 @@ type TransactionResponse struct {
EventId int64 `json:"EventId"` EventId int64 `json:"EventId"`
Success bool `json:"Success"` Success bool `json:"Success"`
} }
type TransactionStatus string
const (
PaymentSuccessful TransactionStatus = "F"
PaymentPending TransactionStatus = "A"
PaymentPreauthorized TransactionStatus = "C"
PaymentUnsuccessful TransactionStatus = "E"
PaymentRefunded TransactionStatus = "R"
PaymentVoided TransactionStatus = "X"
)
type TransactionStatusResponse struct {
Email string `json:"email"`
Amount int `json:"amount"`
OrderCode database.OrderId `json:"orderCode"`
StatusId TransactionStatus `json:"statusId"`
FullName string `json:"fullName"`
CardNumber string `json:"cardNumber"`
}

View File

@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"io" "io"
"log"
"net/http" "net/http"
"payment-poc/database" "payment-poc/database"
"payment-poc/state" "payment-poc/state"
@ -16,22 +17,67 @@ import (
) )
type Service struct { type Service struct {
Provider *database.PaymentEntryProvider
ShopId string ShopId string
ShopSecret string ShopSecret string
BackendUrl string BackendUrl string
} }
func (s *Service) CreatePaymentUrl(amount int64) (string, error) { func (s *Service) UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error) {
entry, err := s.Provider.CreateEntry(database.PaymentEntry{ var request = StatusCheckRequest{
Gateway: state.GatewayWsPay, Version: "2.0",
State: state.StateInitialized, ShopId: s.ShopId,
TotalAmount: amount, ShoppingCartId: entry.Id.String(),
}) Signature: CalculateStatusCheckSignature(s.ShopId, s.ShopSecret, entry.Id.String()),
if err != nil {
return "", err
} }
return "/wspay/initialize/" + entry.Id.String(), nil
httpResponse, err := createRequest(
"POST",
"https://test.wspay.biz/api/services/statusCheck",
map[string]string{"content-type": "application/json"},
toJson(request),
)
if err != nil {
return database.PaymentEntry{}, err
}
var response StatusCheckResponse
err = readResponse(httpResponse, &response)
if err != nil {
return database.PaymentEntry{}, err
}
if CompareStatusCheckReturnSignature(response.Signature, s.ShopId, s.ShopSecret, response.ActionSuccess, response.ApprovalCode, entry.Id.String()) != nil {
entry.Amount = &response.Amount
newState := determineState(response)
if entry.State != newState && newState != "" {
log.Printf("Updated state for %s: %s -> %s", entry.Id.String(), entry.State, newState)
entry.State = newState
}
entry.State = state.StateCompleted
} else {
return database.PaymentEntry{}, errors.New("invalid signature")
}
return entry, nil
}
func determineState(response StatusCheckResponse) state.PaymentState {
if response.Completed == "1" {
return state.StateCompleted
} else if response.Voided == "1" {
return state.StateVoided
} else if response.Refunded == "1" {
return state.StateVoided
} else if response.Authorized == "1" {
return state.StateAccepted
} else {
return state.StateInitialized
}
}
func (s *Service) CreatePaymentUrl(entry database.PaymentEntry) (database.PaymentEntry, string, error) {
return entry, "/wspay/initialize/" + entry.Id.String(), nil
} }
func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) { func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) {
@ -127,12 +173,12 @@ func (s *Service) InitializePayment(entry database.PaymentEntry) Form {
return form return form
} }
func (s *Service) HandleSuccessResponse(c *gin.Context) (string, error) { func (s *Service) HandleSuccessResponse(c *gin.Context, provider *database.PaymentEntryProvider) (string, error) {
response := FormReturn{} response := FormReturn{}
if err := c.ShouldBind(&response); err != nil { if err := c.ShouldBind(&response); err != nil {
return "", err return "", err
} }
entry, err := s.Provider.FetchById(uuid.MustParse(response.ShoppingCartID)) entry, err := provider.FetchById(uuid.MustParse(response.ShoppingCartID))
if err != nil { if err != nil {
return "", err return "", err
} }
@ -148,19 +194,19 @@ func (s *Service) HandleSuccessResponse(c *gin.Context) (string, error) {
entry.ApprovalCode = &response.ApprovalCode entry.ApprovalCode = &response.ApprovalCode
entry.State = state.StateAccepted entry.State = state.StateAccepted
if _, err := s.Provider.UpdateEntry(entry); err != nil { if _, err := provider.UpdateEntry(entry); err != nil {
return "", err return "", err
} }
return "/entries/" + entry.Id.String(), nil return "/entries/" + entry.Id.String(), nil
} }
func (s *Service) HandleErrorResponse(c *gin.Context, paymentState state.PaymentState) (string, error) { func (s *Service) HandleErrorResponse(c *gin.Context, provider *database.PaymentEntryProvider, paymentState state.PaymentState) (string, error) {
response := FormError{} response := FormError{}
if err := c.ShouldBind(&response); err != nil { if err := c.ShouldBind(&response); err != nil {
return "", err return "", err
} }
entry, err := s.Provider.FetchById(uuid.MustParse(response.ShoppingCartID)) entry, err := provider.FetchById(uuid.MustParse(response.ShoppingCartID))
if err != nil { if err != nil {
return "", err return "", err
} }
@ -176,7 +222,7 @@ func (s *Service) HandleErrorResponse(c *gin.Context, paymentState state.Payment
entry.Error = &response.ErrorMessage entry.Error = &response.ErrorMessage
entry.State = paymentState entry.State = paymentState
if _, err := s.Provider.UpdateEntry(entry); err != nil { if _, err := provider.UpdateEntry(entry); err != nil {
return "", err return "", err
} }
@ -268,6 +314,46 @@ func CompareCompletionReturnSignature(signature string, shopId string, secret st
} }
} }
func CompareStatusCheckReturnSignature(signature string, shopId string, secret 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
ActionSuccess
ApprovalCode
SecretKey
ShopID
ApprovalCode
WsPayOrderId
*/
calculatedSignature := shopId + secret + actionSuccess + approvalCode + secret + shopId + approvalCode + cartId
hash := sha512.New()
hash.Write([]byte(calculatedSignature))
if hex.EncodeToString(hash.Sum(nil)) == signature {
return nil
} else {
return errors.New("signature mismatch")
}
}
func CalculateStatusCheckSignature(shopId string, secret string, cartId string) string {
/**
Represents a signature created from string formatted from following values in a following order using
SHA512 algorithm:
ShopID
SecretKey
ShoppingCartID
SecretKey
ShopID
ShoppingCartID
*/
signature := shopId + secret + cartId + secret + shopId + cartId
hash := sha512.New()
hash.Write([]byte(signature))
return hex.EncodeToString(hash.Sum(nil))
}
func readResponse[T any](httpResponse *http.Response, response T) error { func readResponse[T any](httpResponse *http.Response, response T) error {
if httpResponse.StatusCode == http.StatusOK { if httpResponse.StatusCode == http.StatusOK {
content, err := io.ReadAll(httpResponse.Body) content, err := io.ReadAll(httpResponse.Body)

View File

@ -130,14 +130,14 @@ type StatusCheckResponse struct {
ApprovalCode string ApprovalCode string
ShopID string ShopID string
ShoppingCartID string ShoppingCartID string
Amount string Amount int64
CurrencyCode string CurrencyCode string
ActionSuccess string ActionSuccess string
Success string // deprecated Success string // deprecated
Authorized int Authorized string
Completed int Completed string
Voided int Voided string
Refunded int Refunded string
PaymentPlan string PaymentPlan string
Partner string Partner string
OnSite int OnSite int
@ -151,7 +151,7 @@ type StatusCheckResponse struct {
CustomerCountry string CustomerCountry string
CustomerPhone string CustomerPhone string
CustomerEmail string CustomerEmail string
TransactionDateTime string //yyyymmddHHMMss TransactionDateTime string // yyyymmddHHMMss
IsLessThan30DaysFromTransaction bool IsLessThan30DaysFromTransaction bool
CanBeCompleted bool CanBeCompleted bool
CanBeVoided bool CanBeVoided bool