Reorganized code + migration to go1.22

This commit is contained in:
Borna Rajković 2024-04-01 20:29:24 +02:00
parent e3d77d55b9
commit 7369777098
25 changed files with 825 additions and 605 deletions

332
api/api.go Normal file
View File

@ -0,0 +1,332 @@
package api
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"log/slog"
"net/http"
"payment-poc/domain/database"
"payment-poc/domain/providers"
"payment-poc/domain/providers/mock"
stripe2 "payment-poc/domain/providers/stripe"
"payment-poc/domain/providers/viva"
wspay2 "payment-poc/domain/providers/wspay"
"payment-poc/domain/state"
"strconv"
"time"
)
func NoMethod() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{
"status": 404,
"created": time.Now(),
"message": "no handler for method '" + c.Request.Method + "'",
})
}
}
func NoRoute() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{
"status": 404,
"created": time.Now(),
"message": "no handler for " + c.Request.Method + " '" + c.Request.URL.RequestURI() + "'",
})
}
}
func RefreshPayment(entryProvider *database.PaymentEntryProvider, paymentGateways map[state.PaymentGateway]providers.PaymentProvider) gin.HandlerFunc {
return 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 {
slog.Info("fetching payment info", "entry_id", entry.Id.String(), "state", entry.State)
entry, err = paymentGateway.UpdatePayment(entry)
if err == nil {
entryProvider.UpdateEntry(entry)
slog.Info("fetched payment info", "entry_id", entry.Id.String(), "state", entry.State)
}
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
}
}
}
}
func CancelPayment(entryProvider *database.PaymentEntryProvider, paymentGateways map[state.PaymentGateway]providers.PaymentProvider) gin.HandlerFunc {
return 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 {
slog.Info("canceling payment", "entry_id", id.String(), "state", entry.State)
entry, err = paymentGateway.CancelTransaction(entry)
if err == nil {
entryProvider.UpdateEntry(entry)
slog.Info("canceled payment", "entry_id", entry.Id.String(), "state", entry.State)
c.Redirect(http.StatusSeeOther, "/entries/"+id.String())
} else {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
} else {
if err != nil {
c.AbortWithError(http.StatusInternalServerError, errors.New("payment gateway not supported: "+string(entry.Gateway)))
return
}
}
}
}
func CompletePayment(entryProvider *database.PaymentEntryProvider, paymentGateways map[state.PaymentGateway]providers.PaymentProvider) gin.HandlerFunc {
return 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 {
amount, err := fetchAmount(c.PostForm("amount"))
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
slog.Info("completing payment with amount", "entry_id", id.String(), "state", entry.State, "amount", float64(amount)/100.0)
entry, err = paymentGateway.CompleteTransaction(entry, amount)
if err == nil {
entryProvider.UpdateEntry(entry)
slog.Info("completed payment", "entry_id", id.String(), "state", entry.State)
c.Redirect(http.StatusSeeOther, "/entries/"+id.String())
} else {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
} else {
if err != nil {
c.AbortWithError(http.StatusInternalServerError, errors.New("payment gateway not supported: "+string(entry.Gateway)))
return
}
}
}
}
func GetEntry(provider *database.PaymentEntryProvider) gin.HandlerFunc {
return 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(200, "info.gohtml", gin.H{"Entry": entry})
}
}
func InitializePayment(entryProvider *database.PaymentEntryProvider, paymentGateways map[state.PaymentGateway]providers.PaymentProvider) gin.HandlerFunc {
return func(c *gin.Context) {
gateway, err := fetchGateway(c.Param("gateway"))
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if paymentGateway, contains := paymentGateways[gateway]; contains {
amount, err := fetchAmount(c.Query("amount"))
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
entry, err := entryProvider.CreateEntry(database.PaymentEntry{
Gateway: gateway,
State: state.StatePreinitialized,
TotalAmount: amount,
})
slog.Info("creating payment", "entry_id", entry.Id.String(), "state", entry.State, "gateway", gateway, "amount", float64(amount)/100.0)
if entry, url, err := paymentGateway.CreatePaymentUrl(entry); err == nil {
slog.Info("created redirect url", "entry_id", entry.Id.String(), "state", entry.State)
entryProvider.UpdateEntry(entry)
c.Redirect(http.StatusSeeOther, url)
} else {
c.AbortWithError(http.StatusBadRequest, err)
return
}
} else {
c.AbortWithError(http.StatusBadRequest, errors.New("unsupported payment gateway: "+string(gateway)))
return
}
}
}
func GetGateways(gateways map[state.PaymentGateway]providers.PaymentProvider) gin.HandlerFunc {
return 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, "Gateways": mapGateways(gateways)})
}
}
func GetIndex(provider *database.PaymentEntryProvider) gin.HandlerFunc {
return func(c *gin.Context) {
entries, _ := provider.FetchAll()
c.HTML(200, "index.gohtml", gin.H{"Entries": entries})
}
}
func VivaOnFailure(vivaService viva.Service, provider *database.PaymentEntryProvider) gin.HandlerFunc {
return func(c *gin.Context) {
url, err := vivaService.HandleResponse(c, provider, state.StateError)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
}
}
func VivaOnSuccess(vivaService viva.Service, provider *database.PaymentEntryProvider) gin.HandlerFunc {
return func(c *gin.Context) {
url, err := vivaService.HandleResponse(c, provider, state.StateAccepted)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
}
}
func StripeOnFailure(stripeService stripe2.Service, provider *database.PaymentEntryProvider) gin.HandlerFunc {
return func(c *gin.Context) {
url, err := stripeService.HandleResponse(c, provider, state.StateError)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
}
}
func StripeOnSuccess(stripeService stripe2.Service, provider *database.PaymentEntryProvider) gin.HandlerFunc {
return func(c *gin.Context) {
url, err := stripeService.HandleResponse(c, provider, state.StateAccepted)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
}
}
func WsPayOnFailure(wspayService wspay2.Service, provider *database.PaymentEntryProvider, finalState state.PaymentState) gin.HandlerFunc {
return func(c *gin.Context) {
url, err := wspayService.HandleErrorResponse(c, provider, finalState)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
}
}
func WsPayOnSuccess(wspayService wspay2.Service, provider *database.PaymentEntryProvider) gin.HandlerFunc {
return func(c *gin.Context) {
url, err := wspayService.HandleSuccessResponse(c, provider)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
}
}
func MockOnFailure(mockService mock.Service, provider *database.PaymentEntryProvider) gin.HandlerFunc {
return 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 MockOnSuccess(mockService mock.Service, provider *database.PaymentEntryProvider) gin.HandlerFunc {
return 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)
}
}
func MockOpenGateway(provider *database.PaymentEntryProvider) gin.HandlerFunc {
return 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})
}
}
func mapGateways(gateways map[state.PaymentGateway]providers.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) {
switch gateway {
case string(state.GatewayWsPay):
return state.GatewayWsPay, nil
case string(state.GatewayStripe):
return state.GatewayStripe, nil
case string(state.GatewayVivaWallet):
return state.GatewayVivaWallet, nil
case string(state.GatewayMock):
return state.GatewayMock, nil
}
return "", errors.New("unknown gateway: " + gateway)
}
func fetchAmount(amount string) (int64, error) {
if amount, err := strconv.ParseFloat(amount, 64); err == nil {
return int64(amount * 100), nil
} else {
return 0, err
}
}

39
api/middleware.go Normal file
View File

@ -0,0 +1,39 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"os"
"strings"
)
func Auth() gin.HandlerFunc {
if !hasProfile("no-auth") {
return gin.BasicAuth(loadAuth())
}
return nil
}
func hasProfile(value string) bool {
profileOptions := strings.Split(os.Getenv("PROFILE"), ",")
for _, option := range profileOptions {
if option == value {
return true
}
}
return false
}
func loadAuth() map[string]string {
credentials := envMustExist("AUTH")
values := strings.Split(credentials, ":")
return map[string]string{values[0]: values[1]}
}
func envMustExist(env string) string {
if value, exists := os.LookupEnv(env); !exists {
panic(fmt.Sprintf("env variable '%s' not defined", env))
} else {
return value
}
}

84
api/routes.go Normal file
View File

@ -0,0 +1,84 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"log/slog"
"payment-poc/domain/database"
"payment-poc/domain/providers"
"payment-poc/domain/providers/mock"
stripe2 "payment-poc/domain/providers/stripe"
"payment-poc/domain/providers/viva"
wspay2 "payment-poc/domain/providers/wspay"
"payment-poc/domain/state"
)
func RegisterRoutes(server *gin.Engine, db *sqlx.DB) {
backendUrl := envMustExist("BACKEND_URL")
paymentGateways := map[state.PaymentGateway]providers.PaymentProvider{}
entryProvider := &database.PaymentEntryProvider{DB: db}
server.GET("/", GetIndex(entryProvider))
server.GET("/methods", GetGateways(paymentGateways))
server.GET("/methods/:gateway", InitializePayment(entryProvider, paymentGateways))
server.GET("/entries/:id", GetEntry(entryProvider))
server.POST("/entries/:id/complete", CompletePayment(entryProvider, paymentGateways))
server.POST("/entries/:id/cancel", CancelPayment(entryProvider, paymentGateways))
server.POST("/entries/:id/refresh", RefreshPayment(entryProvider, paymentGateways))
if hasProfile(string(state.GatewayMock)) {
mockService := mock.Service{
BackendUrl: backendUrl,
}
paymentGateways[state.GatewayMock] = &mockService
mockGroup := server.Group("mock")
mockGroup.GET("/gateway/:id", MockOpenGateway(entryProvider))
mockGroup.GET("success", MockOnSuccess(mockService, entryProvider))
mockGroup.GET("error", MockOnFailure(mockService, entryProvider))
slog.Info("Registered provider", slog.Any("provider", state.GatewayMock))
}
if hasProfile(string(state.GatewayWsPay)) {
wspayService := wspay2.Service{
ShopId: envMustExist("WSPAY_SHOP_ID"),
ShopSecret: envMustExist("WSPAY_SHOP_SECRET"),
BackendUrl: backendUrl,
}
paymentGateways[state.GatewayWsPay] = &wspayService
wspayGroup := server.Group("wspay")
wspayGroup.GET("success", WsPayOnSuccess(wspayService, entryProvider))
wspayGroup.GET("error", WsPayOnFailure(wspayService, entryProvider, state.StateError))
wspayGroup.GET("cancel", WsPayOnFailure(wspayService, entryProvider, state.StateCanceled))
slog.Info("Registered provider", slog.Any("provider", state.GatewayWsPay))
}
if hasProfile(string(state.GatewayStripe)) {
stripeService := stripe2.Service{
ApiKey: envMustExist("STRIPE_KEY"),
BackendUrl: backendUrl,
}
paymentGateways[state.GatewayStripe] = &stripeService
stripeGroup := server.Group("stripe")
stripeGroup.GET("success", StripeOnSuccess(stripeService, entryProvider))
stripeGroup.GET("error", StripeOnFailure(stripeService, entryProvider))
slog.Info("Registered provider", slog.Any("provider", state.GatewayStripe))
}
if hasProfile(string(state.GatewayVivaWallet)) {
vivaService := viva.Service{
ClientId: envMustExist("VIVA_WALLET_CLIENT_ID"),
ClientSecret: envMustExist("VIVA_WALLET_CLIENT_SECRET"),
SourceCode: envMustExist("VIVA_WALLET_SOURCE_CODE"),
MerchantId: envMustExist("VIVA_WALLET_MERCHANT_ID"),
ApiKey: envMustExist("VIVA_WALLET_API_KEY"),
}
paymentGateways[state.GatewayVivaWallet] = &vivaService
vivaGroup := server.Group("viva")
vivaGroup.GET("success", VivaOnSuccess(vivaService, entryProvider))
vivaGroup.GET("error", VivaOnFailure(vivaService, entryProvider))
slog.Info("Registered provider", slog.Any("provider", state.GatewayVivaWallet))
}
}

25
api/server.go Normal file
View File

@ -0,0 +1,25 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
)
func SetupServer(db *sqlx.DB) *gin.Engine {
server := createServer()
LoadTemplates(server)
RegisterRoutes(server, db)
return server
}
func createServer() *gin.Engine {
server := gin.New()
server.NoRoute(NoRoute())
server.NoMethod(NoMethod())
server.Use(gin.Recovery())
auth := Auth()
if auth != nil {
server.Use(Auth())
}
return server
}

64
api/templates.go Normal file
View File

@ -0,0 +1,64 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"html/template"
"payment-poc/domain/state"
)
func LoadTemplates(server *gin.Engine) {
server.SetFuncMap(template.FuncMap{
"formatCurrency": formatCurrency,
"formatCurrencyPtr": formatCurrencyPtr,
"decimalCurrency": decimalCurrency,
"formatState": formatState,
"omitempty": omitempty,
})
server.LoadHTMLGlob("./templates/*.gohtml")
}
func formatState(stt state.PaymentState) string {
switch stt {
case state.StateCanceled:
return "Otkazana"
case state.StateVoided:
return "Poništena"
case state.StateAccepted:
return "Predautorizirana"
case state.StateError:
return "Greška"
case state.StatePreinitialized:
return "Predinicijalizirana"
case state.StateInitialized:
return "Inicijalizirana"
case state.StateCanceledInitialization:
return "Otkazana tijekom izrade"
case state.StateCompleted:
return "Autorizirana"
}
return "nepoznato stanje '" + string(stt) + "'"
}
func formatCurrency(current int64) string {
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 {
return fmt.Sprintf("%d,%02d", current/100, current%100)
}
func omitempty(value string) string {
if value == "" {
return "-"
}
return value
}

14
db.go
View File

@ -15,15 +15,25 @@ func envMustExist(env string) string {
}
}
func envOrDefault(env string, defaultValue string) string {
if value, exists := os.LookupEnv(env); exists {
return value
} else {
return defaultValue
}
}
func connectToDb() (*sqlx.DB, error) {
host := envMustExist("PSQL_HOST")
port := envMustExist("PSQL_PORT")
user := envMustExist("PSQL_USER")
password := envMustExist("PSQL_PASSWORD")
dbname := envMustExist("PSQL_DB")
sslMode := envOrDefault("PSQL_SSLMODE", "disable")
schema := envOrDefault("PSQL_SCHEMA", "public")
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s search_path=%s",
host, port, user, password, dbname, sslMode, schema)
db, err := sqlx.Open("postgres", psqlInfo)
if err != nil {

View File

@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS "payment_entry"
"payment_intent_id" varchar(255) DEFAULT NULL,
"ws_pay_order_id" varchar(255) DEFAULT '',
"shopping_card_id" varchar(255) DEFAULT NULL,
"stan" varchar(255) DEFAULT NULL,
"success" int DEFAULT NULL,

View File

@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS "payment_entry"
(
"id" uuid NOT NULL,
"created" timestamp NOT NULL,
"modified" timestamp DEFAULT NULL,
"gateway" varchar(255) NOT NULL,
"state" varchar(255) NOT NULL,
"lang" varchar(16) DEFAULT NULL,
"error" varchar(255) DEFAULT NULL,
"amount" int DEFAULT NULL,
"total_amount" int NOT NULL,
"eci" varchar(255) DEFAULT NULL,
"payment_intent_id" varchar(255) DEFAULT NULL,
"ws_pay_order_id" varchar(255) DEFAULT '',
"shopping_card_id" varchar(255) DEFAULT NULL,
"stan" varchar(255) DEFAULT NULL,
"success" int DEFAULT NULL,
"approval_code" varchar(255) DEFAULT NULL,
"order_id" varchar(255) DEFAULT NULL,
"transaction_id" uuid DEFAULT NULL,
"event_id" varchar(255) DEFAULT NULL,
PRIMARY KEY (id)
);

View File

@ -2,7 +2,7 @@ package database
import (
"github.com/google/uuid"
"payment-poc/state"
"payment-poc/domain/state"
"time"
)
@ -31,6 +31,7 @@ type PaymentEntry struct {
PaymentIntentId *string `db:"payment_intent_id"`
// wspay field
WsPayOrderId string `db:"ws_pay_order_id"`
ShoppingCardID *string `db:"shopping_card_id"`
STAN *string `db:"stan"`
Success *int `db:"success"`

View File

@ -29,8 +29,8 @@ func (p *PaymentEntryProvider) UpdateEntry(entry PaymentEntry) (PaymentEntry, er
currentTime := time.Now()
entry.Modified = &currentTime
_, err := p.DB.Exec(`UPDATE "payment_entry" SET "modified" = $2, "state" = $3, "lang" = $4, "error" = $5, "amount" = $6, "eci" = $7, "payment_intent_id" = $8, "shopping_card_id" = $9, "stan" = $10, "success" = $11, "approval_code" = $12, "order_id" = $13, "transaction_id" = $14, "event_id" = $15 WHERE "id" = $1`,
&entry.Id, &entry.Modified, &entry.State, &entry.Lang, &entry.Error, &entry.Amount, &entry.ECI, &entry.PaymentIntentId, &entry.ShoppingCardID, &entry.STAN, &entry.Success, &entry.ApprovalCode, &entry.OrderId, &entry.TransactionId, &entry.EventId,
_, err := p.DB.Exec(`UPDATE "payment_entry" SET "modified" = $2, "state" = $3, "lang" = $4, "error" = $5, "amount" = $6, "eci" = $7, "payment_intent_id" = $8, "shopping_card_id" = $9, "stan" = $10, "success" = $11, "approval_code" = $12, "order_id" = $13, "transaction_id" = $14, "event_id" = $15, "ws_pay_order_id" = $16 WHERE "id" = $1`,
&entry.Id, &entry.Modified, &entry.State, &entry.Lang, &entry.Error, &entry.Amount, &entry.ECI, &entry.PaymentIntentId, &entry.ShoppingCardID, &entry.STAN, &entry.Success, &entry.ApprovalCode, &entry.OrderId, &entry.TransactionId, &entry.EventId, &entry.WsPayOrderId,
)
if err != nil {
return PaymentEntry{}, err

View File

@ -0,0 +1,44 @@
package mock
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"log/slog"
database2 "payment-poc/domain/database"
"payment-poc/domain/state"
)
type Service struct {
BackendUrl string
}
func (s Service) UpdatePayment(entry database2.PaymentEntry) (updatedEntry database2.PaymentEntry, err error) {
return entry, nil
}
func (s Service) CreatePaymentUrl(entry database2.PaymentEntry) (updateEntry database2.PaymentEntry, url string, err error) {
return entry, "/mock/gateway/" + entry.Id.String(), nil
}
func (s Service) CompleteTransaction(entry database2.PaymentEntry, amount int64) (database2.PaymentEntry, error) {
entry.Amount = &amount
entry.State = state.StateCompleted
return entry, nil
}
func (s Service) CancelTransaction(entry database2.PaymentEntry) (database2.PaymentEntry, error) {
entry.State = state.StateVoided
return entry, nil
}
func (s Service) HandleResponse(c *gin.Context, provider *database2.PaymentEntryProvider, paymentState state.PaymentState) (string, error) {
id := uuid.MustParse(c.Query("id"))
entry, err := provider.FetchById(id)
if err != nil {
return "", err
}
entry.State = paymentState
_, err = provider.UpdateEntry(entry)
slog.Info("received authorization response", "entry_id", entry.Id.String(), "state", entry.State)
return "/entries/" + id.String(), err
}

View File

@ -0,0 +1,10 @@
package providers
import "payment-poc/domain/database"
type PaymentProvider interface {
CreatePaymentUrl(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, url string, err error)
CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error)
CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error)
UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error)
}

View File

@ -6,9 +6,9 @@ import (
"github.com/stripe/stripe-go/v72"
"github.com/stripe/stripe-go/v72/checkout/session"
"github.com/stripe/stripe-go/v72/paymentintent"
"log"
"payment-poc/database"
"payment-poc/state"
"log/slog"
database2 "payment-poc/domain/database"
"payment-poc/domain/state"
)
type Service struct {
@ -16,15 +16,19 @@ type Service struct {
BackendUrl string
}
func (s *Service) UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error) {
pi, err := paymentintent.Get(*entry.PaymentIntentId, nil)
func (s *Service) UpdatePayment(entry database2.PaymentEntry) (updatedEntry database2.PaymentEntry, err error) {
client := paymentintent.Client{
B: stripe.GetBackend(stripe.APIBackend),
Key: s.ApiKey,
}
pi, err := client.Get(*entry.PaymentIntentId, nil)
if err != nil {
return entry, err
}
newState := determineState(pi.Status)
if entry.State != newState && newState != "" {
log.Printf("[%s] updated state for %s -> %s", entry.Id.String(), entry.State, newState)
slog.Info("updated state", "entry_id", entry.Id.String(), "state", entry.State, "new_state", newState)
if pi.AmountReceived > 0 {
entry.Amount = &pi.AmountReceived
}
@ -53,7 +57,7 @@ func determineState(status stripe.PaymentIntentStatus) state.PaymentState {
return ""
}
func (s *Service) CreatePaymentUrl(entry database.PaymentEntry) (database.PaymentEntry, string, error) {
func (s *Service) CreatePaymentUrl(entry database2.PaymentEntry) (database2.PaymentEntry, string, error) {
entry, url, err := s.InitializePayment(entry)
if err != nil {
return entry, "", err
@ -61,7 +65,7 @@ func (s *Service) CreatePaymentUrl(entry database.PaymentEntry) (database.Paymen
return entry, url, nil
}
func (s *Service) InitializePayment(entry database.PaymentEntry) (database.PaymentEntry, string, error) {
func (s *Service) InitializePayment(entry database2.PaymentEntry) (database2.PaymentEntry, string, error) {
currency := string(stripe.CurrencyEUR)
productName := "Example product"
@ -88,9 +92,13 @@ func (s *Service) InitializePayment(entry database.PaymentEntry) (database.Payme
SuccessURL: stripe.String(s.BackendUrl + "/stripe/success?token=" + entry.Id.String()),
CancelURL: stripe.String(s.BackendUrl + "/stripe/cancel?token=" + entry.Id.String()),
}
result, err := session.New(params)
client := session.Client{
B: stripe.GetBackend(stripe.APIBackend),
Key: s.ApiKey,
}
result, err := client.New(params)
if err != nil {
return database.PaymentEntry{}, "", err
return database2.PaymentEntry{}, "", err
}
entry.State = state.StateInitialized
entry.PaymentIntentId = &result.PaymentIntent.ID
@ -98,15 +106,19 @@ func (s *Service) InitializePayment(entry database.PaymentEntry) (database.Payme
return entry, result.URL, nil
}
func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) {
func (s *Service) CompleteTransaction(entry database2.PaymentEntry, amount int64) (database2.PaymentEntry, error) {
params := &stripe.PaymentIntentCaptureParams{
AmountToCapture: stripe.Int64(amount),
}
pi, err := paymentintent.Capture(*entry.PaymentIntentId, params)
if err != nil {
return database.PaymentEntry{}, err
client := paymentintent.Client{
B: stripe.GetBackend(stripe.APIBackend),
Key: s.ApiKey,
}
log.Printf("received state on completion: %v", pi.Status)
pi, err := client.Capture(*entry.PaymentIntentId, params)
if err != nil {
return database2.PaymentEntry{}, err
}
slog.Info("received state on completion", "entry_id", entry.Id.String(), "state", entry.State, "new_state", pi.Status)
newState := determineState(pi.Status)
entry.State = newState
if newState == state.StateCompleted || newState == state.StatePending {
@ -115,20 +127,24 @@ func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64)
return entry, nil
}
func (s *Service) CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error) {
func (s *Service) CancelTransaction(entry database2.PaymentEntry) (database2.PaymentEntry, error) {
params := &stripe.PaymentIntentCancelParams{}
pi, err := paymentintent.Cancel(*entry.PaymentIntentId, params)
if err != nil {
return database.PaymentEntry{}, err
client := paymentintent.Client{
B: stripe.GetBackend(stripe.APIBackend),
Key: s.ApiKey,
}
log.Printf("received state on completion: %v", pi.Status)
pi, err := client.Cancel(*entry.PaymentIntentId, params)
if err != nil {
return database2.PaymentEntry{}, err
}
slog.Info("received state on completion", "entry_id", entry.Id.String(), "state", entry.State, "new_state", pi.Status)
if pi.Status == stripe.PaymentIntentStatusCanceled {
entry.State = state.StateCanceled
}
return entry, nil
}
func (s *Service) HandleResponse(c *gin.Context, provider *database.PaymentEntryProvider, paymentState state.PaymentState) (string, error) {
func (s *Service) HandleResponse(c *gin.Context, provider *database2.PaymentEntryProvider, paymentState state.PaymentState) (string, error) {
id := uuid.MustParse(c.Query("token"))
entry, err := provider.FetchById(id)
if err != nil {
@ -138,6 +154,6 @@ func (s *Service) HandleResponse(c *gin.Context, provider *database.PaymentEntry
if _, err := provider.UpdateEntry(entry); err != nil {
return "", err
}
log.Printf("[%s:%s] received authorization response", entry.Id.String(), entry.State)
slog.Info("received authorization response", "entry_id", entry.Id.String(), "state", entry.State)
return "/entries/" + entry.Id.String(), nil
}

View File

@ -8,11 +8,11 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"io"
"log"
"log/slog"
"net/http"
"net/url"
"payment-poc/database"
"payment-poc/state"
database2 "payment-poc/domain/database"
"payment-poc/domain/state"
"strconv"
"time"
)
@ -29,7 +29,7 @@ type Service struct {
expiration time.Time
}
func (s *Service) UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error) {
func (s *Service) UpdatePayment(entry database2.PaymentEntry) (updatedEntry database2.PaymentEntry, err error) {
token, err := s.oAuthToken()
httpResponse, err := createRequest(
"GET",
@ -38,19 +38,23 @@ func (s *Service) UpdatePayment(entry database.PaymentEntry) (updatedEntry datab
[]byte{},
)
if err != nil {
return database.PaymentEntry{}, err
return database2.PaymentEntry{}, err
}
var response TransactionStatusResponse
err = readResponse(httpResponse, &response)
if err != nil {
return database.PaymentEntry{}, err
return database2.PaymentEntry{}, err
}
newState := determineStatus(response.StatusId)
if entry.State != newState && newState != "" {
log.Printf("[%s:%s] updated state %s -> %s", entry.Id.String(), entry.State, entry.State, newState)
slog.Info("updated state", "entry_id", entry.Id.String(), "state", entry.State, "new_state", newState)
entry.State = newState
if entry.State == state.StateCompleted {
amount := int64(response.Amount * 100)
entry.Amount = &amount
}
}
return entry, nil
}
@ -70,11 +74,11 @@ func determineStatus(id TransactionStatus) state.PaymentState {
case PaymentVoided:
return state.StateVoided
}
log.Printf("Unknonw transactionStatus: %s", string(id))
slog.Info("unknown transaction status", "status", string(id))
return ""
}
func (s *Service) CreatePaymentUrl(entry database.PaymentEntry) (database.PaymentEntry, string, error) {
func (s *Service) CreatePaymentUrl(entry database2.PaymentEntry) (database2.PaymentEntry, string, error) {
entry, err := s.InitializePayment(entry)
if err != nil {
return entry, "", err
@ -82,10 +86,10 @@ func (s *Service) CreatePaymentUrl(entry database.PaymentEntry) (database.Paymen
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 database2.PaymentEntry) (database2.PaymentEntry, error) {
token, err := s.oAuthToken()
if err != nil {
return database.PaymentEntry{}, err
return database2.PaymentEntry{}, err
}
request := OrderRequest{
@ -104,20 +108,20 @@ func (s *Service) InitializePayment(entry database.PaymentEntry) (database.Payme
toJson(request),
)
if err != nil {
return database.PaymentEntry{}, err
return database2.PaymentEntry{}, err
}
var response OrderResponse
err = readResponse(httpResponse, &response)
if err != nil {
return database.PaymentEntry{}, err
return database2.PaymentEntry{}, err
}
entry.State = state.StateInitialized
entry.OrderId = &response.OrderId
return entry, nil
}
func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) {
func (s *Service) CompleteTransaction(entry database2.PaymentEntry, amount int64) (database2.PaymentEntry, error) {
completionRequest := TransactionCompleteRequest{
Amount: amount,
CustomerDescription: "Example transaction",
@ -125,54 +129,55 @@ func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64)
httpResponse, err := createRequest(
"POST",
"https://demo.vivapayments.com/api/transactions/"+entry.TransactionId.String(),
map[string]string{"authorization": "Bearer " + s.basicAuth(),
map[string]string{"authorization": "Basic " + s.basicAuth(),
"content-type": "application/json",
},
toJson(completionRequest),
)
if err != nil {
return database.PaymentEntry{}, err
return database2.PaymentEntry{}, err
}
var response TransactionResponse
err = readResponse(httpResponse, &response)
if err != nil {
return database.PaymentEntry{}, err
return database2.PaymentEntry{}, err
}
if response.StatusId == "F" {
paidAmount := int64(response.Amount * 100)
paidAmount := response.Amount * 100
entry.Amount = &paidAmount
entry.State = state.StateCompleted
} else {
return database.PaymentEntry{}, errors.New("received invalid status = " + response.StatusId)
return database2.PaymentEntry{}, errors.New("received invalid status = " + response.StatusId)
}
return entry, nil
}
func (s *Service) CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error) {
func (s *Service) CancelTransaction(entry database2.PaymentEntry) (database2.PaymentEntry, error) {
amount := strconv.FormatInt(entry.TotalAmount, 10)
httpResponse, err := createRequest(
"DELETE",
"https://demo.vivapayments.com/api/transactions/"+entry.TransactionId.String(),
map[string]string{"authorization": "Bearer " + s.basicAuth(),
"https://demo.vivapayments.com/api/transactions/"+entry.TransactionId.String()+"?amount="+amount,
map[string]string{"authorization": "Basic " + s.basicAuth(),
"content-type": "application/json",
},
nil,
)
if err != nil {
return database.PaymentEntry{}, err
return database2.PaymentEntry{}, err
}
var response TransactionResponse
err = readResponse(httpResponse, &response)
if err != nil {
return database.PaymentEntry{}, err
return database2.PaymentEntry{}, err
}
if response.StatusId == "F" {
paidAmount := int64(0)
entry.Amount = &paidAmount
entry.State = state.StateVoided
} else {
return database.PaymentEntry{}, errors.New("received invalid status = " + response.StatusId)
return database2.PaymentEntry{}, errors.New("received invalid status = " + response.StatusId)
}
return entry, nil
}
@ -241,17 +246,17 @@ func (s *Service) basicAuth() string {
return base64.StdEncoding.EncodeToString([]byte(s.MerchantId + ":" + s.ApiKey))
}
func (s *Service) HandleResponse(c *gin.Context, provider *database.PaymentEntryProvider, state state.PaymentState) (string, error) {
func (s *Service) HandleResponse(c *gin.Context, provider *database2.PaymentEntryProvider, state state.PaymentState) (string, error) {
transactionId := uuid.MustParse(c.Query("t"))
orderId := database.OrderId(c.Query("s"))
orderId := database2.OrderId(c.Query("s"))
lang := c.Query("lang")
eventId := c.Query("eventId")
eci := c.Query("eci")
log.Printf("[%s] received error response for viva payment", orderId)
slog.Info("received error response from viva payment", "order_id", orderId)
entry, err := provider.FetchByOrderId(orderId)
if err != nil {
log.Printf("[%s] couldn't find payment info for viva payment", orderId)
slog.Error("couldn't find payment info for viva payment", "order_id", orderId)
return "", err
}
@ -265,7 +270,7 @@ func (s *Service) HandleResponse(c *gin.Context, provider *database.PaymentEntry
return "", err
}
log.Printf("[%s:%s] received authorization response", entry.Id.String(), entry.State)
slog.Info("received authorization response", "entry_id", entry.Id.String(), "state", entry.State)
return "/entries/" + entry.Id.String(), nil
}

View File

@ -1,6 +1,8 @@
package viva
import "payment-poc/database"
import (
"payment-poc/domain/database"
)
type OrderRequest struct {
Amount int64 `json:"amount"`
@ -26,7 +28,7 @@ type TransactionCompleteRequest struct {
}
type TransactionResponse struct {
Amount float64 `json:"Amount"`
Amount int64 `json:"Amount"`
StatusId string `json:"StatusId"`
ErrorCode int64 `json:"ErrorCode"`
ErrorText string `json:"ErrorText"`
@ -37,9 +39,9 @@ type TransactionResponse struct {
type TransactionStatus string
const (
PaymentSuccessful TransactionStatus = "F"
PaymentSuccessful TransactionStatus = "C"
PaymentPending TransactionStatus = "A"
PaymentPreauthorized TransactionStatus = "C"
PaymentPreauthorized TransactionStatus = "F"
PaymentUnsuccessful TransactionStatus = "E"
PaymentRefunded TransactionStatus = "R"
PaymentVoided TransactionStatus = "X"
@ -47,7 +49,7 @@ const (
type TransactionStatusResponse struct {
Email string `json:"email"`
Amount int `json:"amount"`
Amount float64 `json:"amount"`
OrderCode database.OrderId `json:"orderCode"`
StatusId TransactionStatus `json:"statusId"`
FullName string `json:"fullName"`

View File

@ -10,10 +10,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"io"
"log"
"log/slog"
"net/http"
"payment-poc/database"
"payment-poc/state"
"payment-poc/domain/database"
"payment-poc/domain/state"
"strconv"
)
@ -26,7 +26,7 @@ type Service struct {
func (s *Service) UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error) {
var request = StatusCheckRequest{
Version: "2.0",
ShopId: s.ShopId,
ShopID: s.ShopId,
ShoppingCartId: entry.Id.String(),
Signature: CalculateStatusCheckSignature(s.ShopId, s.ShopSecret, entry.Id.String()),
}
@ -47,16 +47,16 @@ func (s *Service) UpdatePayment(entry database.PaymentEntry) (updatedEntry datab
return database.PaymentEntry{}, err
}
if CompareStatusCheckReturnSignature(response.Signature, s.ShopId, s.ShopSecret, response.ActionSuccess, response.ApprovalCode, entry.Id.String()) != nil {
entry.Amount = &response.Amount
if CompareStatusCheckReturnSignature(response.Signature, s.ShopId, s.ShopSecret, response.ActionSuccess, response.ApprovalCode, entry.WsPayOrderId) == nil {
newValue := int64(response.Amount * 100)
entry.Amount = &newValue
newState := determineState(response)
if entry.State != newState && newState != "" {
log.Printf("Updated state for %s: %s -> %s", entry.Id.String(), entry.State, newState)
slog.Info("Updated state", "entry_id", entry.Id.String(), "state", entry.State, "new_state", newState)
entry.State = newState
}
entry.State = state.StateCompleted
entry.WsPayOrderId = response.WsPayOrderId
} else {
return database.PaymentEntry{}, errors.New("invalid signature")
}
@ -89,12 +89,12 @@ func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64)
if entry.State == state.StateAccepted {
var request = CompletionRequest{
Version: "2.0",
WsPayOrderId: entry.Id.String(),
ShopId: s.ShopId,
WsPayOrderId: entry.WsPayOrderId,
ShopID: s.ShopId,
ApprovalCode: *entry.ApprovalCode,
STAN: *entry.STAN,
Amount: amount,
Signature: CalculateCompletionSignature(s.ShopId, s.ShopSecret, entry.Id.String(), *entry.STAN, *entry.ApprovalCode, amount),
Amount: strconv.FormatInt(amount, 10),
Signature: CalculateCompletionSignature(s.ShopId, s.ShopSecret, entry.WsPayOrderId, *entry.STAN, *entry.ApprovalCode, amount),
}
httpResponse, err := createRequest(
@ -113,7 +113,7 @@ func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64)
return database.PaymentEntry{}, err
}
if CompareCompletionReturnSignature(response.Signature, s.ShopId, s.ShopSecret, entry.Id.String(), *entry.STAN, response.ActionSuccess, response.ApprovalCode) != nil {
if CompareCompletionReturnSignature(response.Signature, s.ShopId, s.ShopSecret, *entry.STAN, response.ActionSuccess, response.ApprovalCode, entry.WsPayOrderId) == nil {
entry.Amount = &amount
entry.State = state.StateCompleted
} else {
@ -129,12 +129,12 @@ func (s *Service) CancelTransaction(entry database.PaymentEntry) (database.Payme
if entry.State == state.StateAccepted {
var request = CompletionRequest{
Version: "2.0",
WsPayOrderId: entry.Id.String(),
ShopId: s.ShopId,
WsPayOrderId: entry.WsPayOrderId,
ShopID: s.ShopId,
ApprovalCode: *entry.ApprovalCode,
STAN: *entry.STAN,
Amount: entry.TotalAmount,
Signature: CalculateCompletionSignature(s.ShopId, s.ShopSecret, entry.Id.String(), *entry.STAN, *entry.ApprovalCode, entry.TotalAmount),
Amount: strconv.FormatInt(entry.TotalAmount, 10),
Signature: CalculateCompletionSignature(s.ShopId, s.ShopSecret, entry.WsPayOrderId, *entry.STAN, *entry.ApprovalCode, entry.TotalAmount),
}
httpResponse, err := createRequest(
@ -153,8 +153,8 @@ func (s *Service) CancelTransaction(entry database.PaymentEntry) (database.Payme
return database.PaymentEntry{}, err
}
if CompareCompletionReturnSignature(response.Signature, s.ShopId, s.ShopSecret, entry.Id.String(), *entry.STAN, response.ActionSuccess, response.ApprovalCode) != nil {
entry.State = state.StateCanceled
if CompareCompletionReturnSignature(response.Signature, s.ShopId, s.ShopSecret, *entry.STAN, response.ActionSuccess, response.ApprovalCode, entry.WsPayOrderId) == nil {
entry.State = state.StateVoided
} else {
return database.PaymentEntry{}, errors.New("invalid signature")
}
@ -222,6 +222,7 @@ func (s *Service) HandleSuccessResponse(c *gin.Context, provider *database.Payme
entry.Success = &response.Success
entry.ApprovalCode = &response.ApprovalCode
entry.State = state.StateAccepted
entry.WsPayOrderId = response.WsPayOrderId
if _, err := provider.UpdateEntry(entry); err != nil {
return "", err
@ -275,7 +276,7 @@ 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 {
func CalculateCompletionSignature(shopId string, secret string, wsPayOrderId 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:
@ -290,7 +291,7 @@ func CalculateCompletionSignature(shopId string, secret string, cartId string, s
SecretKey
WsPayOrderId
*/
signature := shopId + cartId + secret + stan + secret + approvalCode + secret + strconv.FormatInt(amount, 10) + secret + cartId
signature := shopId + wsPayOrderId + secret + stan + secret + approvalCode + secret + strconv.FormatInt(amount, 10) + secret + wsPayOrderId
hash := sha512.New()
hash.Write([]byte(signature))
return hex.EncodeToString(hash.Sum(nil))
@ -320,7 +321,7 @@ func CompareFormReturnSignature(signature string, shopId string, secret string,
}
}
func CompareCompletionReturnSignature(signature string, shopId string, secret string, stan string, actionSuccess string, approvalCode string, cartId string) error {
func CompareCompletionReturnSignature(signature string, shopId string, secret string, stan string, actionSuccess string, approvalCode string, wsPayOrderId string) error {
/**
Represents a signature created from string formatted from following values in a following order using
SHA512 algorithm:
@ -333,7 +334,7 @@ func CompareCompletionReturnSignature(signature string, shopId string, secret st
WsPayOrderId
Merchant should validate this signature to make sure that the request is originating from WSPayForm.
*/
calculatedSignature := shopId + secret + stan + actionSuccess + secret + approvalCode + cartId
calculatedSignature := shopId + secret + stan + actionSuccess + secret + approvalCode + wsPayOrderId
hash := sha512.New()
hash.Write([]byte(calculatedSignature))
if hex.EncodeToString(hash.Sum(nil)) == signature {
@ -343,7 +344,7 @@ func CompareCompletionReturnSignature(signature string, shopId string, secret st
}
}
func CompareStatusCheckReturnSignature(signature string, shopId string, secret string, actionSuccess string, approvalCode string, cartId string) error {
func CompareStatusCheckReturnSignature(signature string, shopId string, secret string, actionSuccess string, approvalCode string, wsPayOrderId string) error {
/**
Represents a signature created from string formatted from following values in a following order using
SHA512 algorithm:
@ -356,7 +357,7 @@ func CompareStatusCheckReturnSignature(signature string, shopId string, secret s
ApprovalCode
WsPayOrderId
*/
calculatedSignature := shopId + secret + actionSuccess + approvalCode + secret + shopId + approvalCode + cartId
calculatedSignature := shopId + secret + actionSuccess + approvalCode + secret + shopId + approvalCode + wsPayOrderId
hash := sha512.New()
hash.Write([]byte(calculatedSignature))
if hex.EncodeToString(hash.Sum(nil)) == signature {

View File

@ -84,16 +84,16 @@ type FormCancel struct {
type CompletionRequest struct {
Version string
WsPayOrderId string
ShopId string
ShopID string
ApprovalCode string
STAN string
Amount int64
Amount string
Signature string
}
type CompletionResponse struct {
WsPayOrderId string
ShopId string
ShopID string
ApprovalCode string
STAN string
ErrorMessage string
@ -103,7 +103,7 @@ type CompletionResponse struct {
type StatusCheckRequest struct {
Version string
ShopId string
ShopID string
ShoppingCartId string
Signature string
}
@ -115,8 +115,8 @@ type StatusCheckResponse struct {
ApprovalCode string
ShopID string
ShoppingCartID string
Amount int64
CurrencyCode string
Amount float64
CurrencyCode int
ActionSuccess string
Success string // deprecated
Authorized string
@ -125,7 +125,7 @@ type StatusCheckResponse struct {
Refunded string
PaymentPlan string
Partner string
OnSite int
OnSite string
CreditCardName string
CreditCardNumber string
ECI string

4
go.mod
View File

@ -1,6 +1,6 @@
module payment-poc
go 1.19
go 1.22
require (
github.com/gin-gonic/gin v1.9.1
@ -8,6 +8,7 @@ require (
github.com/jmoiron/sqlx v1.3.5
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/stripe/stripe-go/v72 v72.122.0
)
require (
@ -26,7 +27,6 @@ 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

446
main.go
View File

@ -2,399 +2,51 @@ package main
import (
"embed"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/stripe/stripe-go/v72"
"html/template"
"log"
"log/slog"
"net/http"
"os"
"payment-poc/database"
"payment-poc/api"
"payment-poc/migration"
"payment-poc/providers/mock"
stripe2 "payment-poc/providers/stripe"
"payment-poc/providers/viva"
wspay2 "payment-poc/providers/wspay"
"payment-poc/state"
"strconv"
"runtime/debug"
"strings"
"time"
)
//go:embed db/dev/*.sql
var devMigrations embed.FS
type PaymentProvider interface {
CreatePaymentUrl(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, url string, err error)
CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error)
CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error)
UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error)
}
func init() {
godotenv.Load()
log.SetPrefix("")
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
if !hasProfile("dev") {
gin.SetMode(gin.ReleaseMode)
}
if value := os.Getenv("LOG_FORMAT"); value == "json" {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})))
}
}
func main() {
commit, buildTime := buildInfo()
slog.Info("build info", slog.String("commit", commit), slog.String("time", buildTime))
client, err := connectToDb()
if err != nil {
log.Fatalf("couldn't connect to db: %v", err)
slog.Error("couldn't connect to db", slog.String("err", err.Error()))
os.Exit(1)
}
if err := migration.InitializeMigrations(client, devMigrations); err != nil {
log.Fatalf("couldn't execute migrations: %v", err)
slog.Error("couldn't finish migration", slog.String("err", err.Error()))
os.Exit(1)
}
g := gin.Default()
server := api.SetupServer(client)
if !hasProfile("no-auth") {
g.Use(gin.BasicAuth(getAccounts()))
port := ":" + getOrDefault("SERVER_PORT", "5281")
slog.Info("app is ready", slog.String("port", port))
if err := http.ListenAndServe(port, server); err != nil {
slog.Error("Couldn't start server!\n", slog.Any("err", err.Error()))
}
g.SetFuncMap(template.FuncMap{
"formatCurrency": formatCurrency,
"formatCurrencyPtr": formatCurrencyPtr,
"decimalCurrency": decimalCurrency,
"formatState": formatState,
"omitempty": omitempty,
})
g.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"message": "no action on given url", "created": time.Now()})
})
g.NoMethod(func(c *gin.Context) {
c.JSON(http.StatusMethodNotAllowed, gin.H{"message": "no action on given method", "created": time.Now()})
})
backendUrl := envMustExist("BACKEND_URL")
paymentGateways := map[state.PaymentGateway]PaymentProvider{}
entryProvider := &database.PaymentEntryProvider{DB: client}
g.LoadHTMLGlob("./templates/*.gohtml")
if hasProfile(string(state.GatewayMock)) {
mockService := mock.Service{
BackendUrl: backendUrl,
}
mockHandlers(g.Group("mock"), entryProvider, &mockService)
paymentGateways[state.GatewayMock] = &mockService
log.Printf("Registered provider: %s", state.GatewayMock)
}
if hasProfile(string(state.GatewayWsPay)) {
wspayService := wspay2.Service{
ShopId: envMustExist("WSPAY_SHOP_ID"),
ShopSecret: envMustExist("WSPAY_SHOP_SECRET"),
BackendUrl: backendUrl,
}
wsPayHandlers(g.Group("wspay"), entryProvider, &wspayService)
paymentGateways[state.GatewayWsPay] = &wspayService
log.Printf("Registered provider: %s", state.GatewayWsPay)
}
if hasProfile(string(state.GatewayStripe)) {
stripeService := stripe2.Service{
ApiKey: envMustExist("STRIPE_KEY"),
BackendUrl: backendUrl,
}
stripeHandlers(g.Group("stripe"), entryProvider, &stripeService)
paymentGateways[state.GatewayStripe] = &stripeService
stripe.Key = envMustExist("STRIPE_KEY")
log.Printf("Registered provider: %s", state.GatewayStripe)
}
if hasProfile(string(state.GatewayVivaWallet)) {
vivaService := viva.Service{
ClientId: envMustExist("VIVA_WALLET_CLIENT_ID"),
ClientSecret: envMustExist("VIVA_WALLET_CLIENT_SECRET"),
SourceCode: envMustExist("VIVA_WALLET_SOURCE_CODE"),
MerchantId: envMustExist("VIVA_WALLET_MERCHANT_ID"),
ApiKey: envMustExist("VIVA_WALLET_API_KEY"),
}
vivaHandlers(g.Group("viva"), entryProvider, &vivaService)
paymentGateways[state.GatewayVivaWallet] = &vivaService
log.Printf("Registered provider: %s", state.GatewayVivaWallet)
}
g.GET("/", func(c *gin.Context) {
entries, _ := entryProvider.FetchAll()
c.HTML(200, "index.gohtml", gin.H{"Entries": entries})
})
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, "Gateways": mapGateways(paymentGateways)})
})
g.GET("/methods/:gateway", func(c *gin.Context) {
gateway, err := fetchGateway(c.Param("gateway"))
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if paymentGateway, contains := paymentGateways[gateway]; contains {
amount, err := fetchAmount(c.Query("amount"))
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
entry, err := entryProvider.CreateEntry(database.PaymentEntry{
Gateway: gateway,
State: state.StatePreinitialized,
TotalAmount: amount,
})
log.Printf("[%s:%s] creating payment with gateway '%s' for '%f'", entry.Id.String(), entry.State, gateway, float64(amount)/100.0)
if entry, url, err := paymentGateway.CreatePaymentUrl(entry); err == nil {
log.Printf("[%s:%s] created redirect url", entry.Id, entry.State)
entryProvider.UpdateEntry(entry)
c.Redirect(http.StatusSeeOther, url)
} else {
c.AbortWithError(http.StatusBadRequest, err)
return
}
} else {
c.AbortWithError(http.StatusBadRequest, errors.New("unsupported payment gateway: "+string(gateway)))
return
}
})
g.GET("/entries/:id", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
entry, err := entryProvider.FetchById(id)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
c.HTML(200, "info.gohtml", gin.H{"Entry": entry})
})
g.POST("/entries/:id/complete", 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 {
amount, err := fetchAmount(c.PostForm("amount"))
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
log.Printf("[%s:%s] completing payment with amount %f", id.String(), entry.State, float64(amount)/100.0)
entry, err = paymentGateway.CompleteTransaction(entry, amount)
if err == nil {
entryProvider.UpdateEntry(entry)
log.Printf("[%s:%s] completed payment", id.String(), entry.State)
c.Redirect(http.StatusSeeOther, "/entries/"+id.String())
} else {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
} else {
if err != nil {
c.AbortWithError(http.StatusInternalServerError, errors.New("payment gateway not supported: "+string(entry.Gateway)))
return
}
}
})
g.POST("/entries/:id/cancel", 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 {
log.Printf("[%s:%s] canceling payment", id.String(), entry.State)
entry, err = paymentGateway.CancelTransaction(entry)
if err == nil {
entryProvider.UpdateEntry(entry)
log.Printf("[%s:%s] canceled payment", id.String(), entry.State)
c.Redirect(http.StatusSeeOther, "/entries/"+id.String())
} else {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
} else {
if err != nil {
c.AbortWithError(http.StatusInternalServerError, errors.New("payment gateway not supported: "+string(entry.Gateway)))
return
}
}
})
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 {
log.Printf("[%s:%s] fetching payment info", entry.Id.String(), entry.State)
entry, err = paymentGateway.UpdatePayment(entry)
if err == nil {
entryProvider.UpdateEntry(entry)
log.Printf("[%s:%s] fetched payment info", entry.Id.String(), entry.State)
}
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))
}
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) {
switch gateway {
case string(state.GatewayWsPay):
return state.GatewayWsPay, nil
case string(state.GatewayStripe):
return state.GatewayStripe, nil
case string(state.GatewayVivaWallet):
return state.GatewayVivaWallet, nil
case string(state.GatewayMock):
return state.GatewayMock, nil
}
return "", errors.New("unknown gateway: " + gateway)
}
func getAccounts() gin.Accounts {
auth := strings.Split(envMustExist("AUTH"), ":")
return gin.Accounts{auth[0]: auth[1]}
}
func fetchAmount(amount string) (int64, error) {
if amount, err := strconv.ParseFloat(amount, 64); err == nil {
return int64(amount * 100), nil
} else {
return 0, err
}
}
func vivaHandlers(g *gin.RouterGroup, provider *database.PaymentEntryProvider, vivaService *viva.Service) {
g.GET("success", func(c *gin.Context) {
url, err := vivaService.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 := vivaService.HandleResponse(c, provider, state.StateError)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
})
}
func stripeHandlers(g *gin.RouterGroup, provider *database.PaymentEntryProvider, stripeService *stripe2.Service) {
g.GET("success", func(c *gin.Context) {
url, err := stripeService.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 := stripeService.HandleResponse(c, provider, state.StateError)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
})
}
func wsPayHandlers(g *gin.RouterGroup, provider *database.PaymentEntryProvider, wspayService *wspay2.Service) {
g.GET("success", func(c *gin.Context) {
url, err := wspayService.HandleSuccessResponse(c, provider)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
})
g.GET("error", func(c *gin.Context) {
url, err := wspayService.HandleErrorResponse(c, provider, state.StateError)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
})
g.GET("cancel", func(c *gin.Context) {
url, err := wspayService.HandleErrorResponse(c, provider, state.StateCanceled)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, url)
})
}
func hasProfile(profile string) bool {
@ -407,47 +59,25 @@ func hasProfile(profile string) bool {
return false
}
func formatState(stt state.PaymentState) string {
switch stt {
case state.StateCanceled:
return "Otkazana"
case state.StateVoided:
return "Poništena"
case state.StateAccepted:
return "Predautorizirana"
case state.StateError:
return "Greška"
case state.StatePreinitialized:
return "Predinicijalizirana"
case state.StateInitialized:
return "Inicijalizirana"
case state.StateCanceledInitialization:
return "Otkazana tijekom izrade"
case state.StateCompleted:
return "Autorizirana"
func buildInfo() (string, string) {
revision := ""
buildTime := ""
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" {
revision = setting.Value
} else if setting.Key == "vcs.time" {
buildTime = setting.Value
}
return "nepoznato stanje '" + string(stt) + "'"
}
func formatCurrency(current int64) string {
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 {
return fmt.Sprintf("%d,%02d", current/100, current%100)
}
func omitempty(value string) string {
if value == "" {
return "-"
}
return revision, buildTime
}
func getOrDefault(env string, defaultValue string) string {
if value, present := os.LookupEnv(env); present {
return value
}
return defaultValue
}

View File

@ -1,30 +1,18 @@
# scripts for building app
# requires go 1.19+ and git installed
# requires go 1.22+ and git installed
VERSION := 0.1.0
serve:
go run ./...
setup:
go get
VERSION := $(shell git describe --tags --always)
docker-dev:
docker image build -t registry.s2internal.com/opgdirekt/payment-poc/backend:$(VERSION)-dev .
docker tag registry.s2internal.com/opgdirekt/payment-poc/backend:$(VERSION)-dev registry.s2internal.com/opgdirekt/payment-poc/backend:latest-dev
docker image push registry.s2internal.com/opgdirekt/payment-poc/backend:$(VERSION)-dev
docker image push registry.s2internal.com/opgdirekt/payment-poc/backend:latest-dev
docker image build -t registry.bbr-dev.info/payment-poc/backend/dev:latest .
docker image push registry.bbr-dev.info/payment-poc/backend/dev:latest
docker-prod:
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)
git push origin $(VERSION)
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
test:
go test ./...

View File

@ -4,10 +4,11 @@ import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
"io/fs"
"log"
"log/slog"
"sort"
"strings"
"time"
@ -92,7 +93,8 @@ func validateMigrations(db *sqlx.DB, migrations map[string]Migration, migrationF
}
func executeMigration(db *sqlx.DB, name string, script string) error {
log.Printf("[INFO] script='%s' | migrations - executing", name)
logger := slog.Default().With(slog.String("script", name))
logger.Info("migrations - executing")
tx := db.MustBeginTx(context.Background(), nil)
var err error = nil
if _, e := tx.Exec(script); e != nil {
@ -102,10 +104,10 @@ func executeMigration(db *sqlx.DB, name string, script string) error {
err = e
}
if err != nil {
log.Printf("[ERROR] script='%s' | migrations - failed executing", name)
logger.Error("migrations - failed executing", slog.String("err", err.Error()))
tx.Rollback()
} else {
log.Printf("[INFO] script='%s' | migrations - succesfully executed", name)
logger.Info("migrations - successfully executed")
tx.Commit()
}
return err
@ -119,9 +121,9 @@ func validateMigration(name string, migration Migration, script string) error {
calculatedHash := hash(script)
if calculatedHash != migration.Hash {
err := fmt.Sprintf("migrations - mismatch in hash for %s (expected '%s', calculated '%s')", name, migration.Hash, calculatedHash)
log.Printf("[ERROR] script='%s' err='%s' | migrations - failed executing", script, err)
return fmt.Errorf("migrations - mismatch in hashes for %s", name)
err := errors.New(fmt.Sprintf("migrations - mismatch in hash for %s (expected '%s', calculated '%s')", name, migration.Hash, calculatedHash))
slog.Error("migrations - failed validation", slog.String("script", name), slog.String("err", err.Error()))
return err
}
return nil
}

View File

@ -1,44 +0,0 @@
package mock
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"log"
"payment-poc/database"
"payment-poc/state"
)
type Service struct {
BackendUrl string
}
func (s Service) UpdatePayment(entry database.PaymentEntry) (updatedEntry database.PaymentEntry, err error) {
return entry, nil
}
func (s Service) CreatePaymentUrl(entry database.PaymentEntry) (updateEntry database.PaymentEntry, url string, err error) {
return entry, "/mock/gateway/" + entry.Id.String(), nil
}
func (s Service) CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) {
entry.Amount = &amount
entry.State = state.StateCompleted
return entry, nil
}
func (s Service) CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error) {
entry.State = state.StateVoided
return entry, nil
}
func (s Service) HandleResponse(c *gin.Context, provider *database.PaymentEntryProvider, paymentState state.PaymentState) (string, error) {
id := uuid.MustParse(c.Query("id"))
entry, err := provider.FetchById(id)
if err != nil {
return "", err
}
entry.State = paymentState
_, err = provider.UpdateEntry(entry)
log.Printf("[%s:%s] received authorization response", entry.Id.String(), entry.State)
return "/entries/" + id.String(), err
}

View File

@ -1,21 +0,0 @@
package main
import (
"encoding/json"
"log"
"net/http"
)
func render[T any](w http.ResponseWriter, r *http.Request, status int, response T) error {
if body, err := json.MarshalIndent(response, "", " "); err == nil {
w.Header().Add("content-type", "application/json")
w.WriteHeader(status)
_, err = w.Write(body)
return err
} else {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("couldn't parse response")
_, err = w.Write([]byte{})
return err
}
}

View File

@ -20,8 +20,8 @@
<section class="container">
<h2>Mock gateway {{.Entry.Id.String}}</h2>
<p>{{formatCurrency .Entry.TotalAmount}}</p>
<a href="/providers/mock/success?id={{.Entry.Id.String}}" class="btn btn-success">Potvrdi plaćanje</a>
<a href="/providers/mock/error?id={{.Entry.Id.String}}" class="btn btn-danger">Otkaži plaćanje</a>
<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>