diff --git a/database/model.go b/database/model.go new file mode 100644 index 0000000..906b7df --- /dev/null +++ b/database/model.go @@ -0,0 +1,54 @@ +package database + +import ( + "github.com/google/uuid" + "payment-poc/state" + "time" +) + +type PaymentEntry struct { + Id uuid.UUID `db:"id"` + + Created time.Time `db:"created"` + Modified *time.Time `db:"modified"` + + Gateway state.PaymentGateway `db:"gateway"` + State state.PaymentState `db:"state"` + + Lang *string `db:"lang"` + + Error *string `db:"error"` + + // paid amount + Amount *int64 `db:"amount"` + // preauthorized amount + TotalAmount int64 `db:"total_amount"` + + // used for wspay and viva + ECI *string `db:"eci"` + + // stripe field + PaymentIntentId *string `db:"payment_intent_id"` + + // wspay field + ShoppingCardID *string `db:"shopping_card_it"` + STAN *string `db:"stan"` + Success *int `db:"success"` + ApprovalCode *string `db:"approval_code"` + + // viva field + OrderId *OrderId `db:"order_id"` + TransactionId *uuid.UUID `db:"transaction_id"` + EventId *string `db:"event_id"` +} + +type OrderId string + +func (o OrderId) MarshalJSON() ([]byte, error) { + return []byte(o), nil +} + +func (o *OrderId) UnmarshalJSON(value []byte) error { + *o = OrderId(value) + return nil +} diff --git a/database/provider.go b/database/provider.go new file mode 100644 index 0000000..0050ae7 --- /dev/null +++ b/database/provider.go @@ -0,0 +1,57 @@ +package database + +import ( + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "time" +) + +type PaymentEntryProvider struct { + DB *sqlx.DB +} + +func (p *PaymentEntryProvider) CreateEntry(entry PaymentEntry) (PaymentEntry, error) { + if entry.Id == uuid.Nil { + entry.Id = uuid.Must(uuid.NewRandom()) + } + entry.Created = time.Now() + + _, err := p.DB.Exec(`INSERT INTO "payment_entry" ("id", "created", "gateway", "state", "lang", "error", "amount", "total_amount", "eci", "payment_intent_id", "shopping_card_id", "stan", "success", "approval_code", "order_id", "transaction_id", "event_id")`, + &entry.Id, &entry.Created, &entry.Gateway, &entry.State, &entry.Lang, &entry.Error, &entry.Amount, &entry.TotalAmount, &entry.ECI, &entry.PaymentIntentId, &entry.ShoppingCardID, &entry.STAN, &entry.Success, &entry.ApprovalCode, &entry.OrderId, &entry.TransactionId, &entry.EventId, + ) + if err != nil { + return PaymentEntry{}, err + } + return p.FetchById(entry.Id) +} + +func (p *PaymentEntryProvider) UpdateEntry(entry PaymentEntry) (PaymentEntry, error) { + currentTime := time.Now() + entry.Modified = ¤tTime + + _, 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, + ) + if err != nil { + return PaymentEntry{}, err + } + return p.FetchById(entry.Id) +} + +func (p *PaymentEntryProvider) FetchById(id uuid.UUID) (PaymentEntry, error) { + entry := PaymentEntry{} + err := p.DB.Get(&entry, `SELECT * FROM "payment_entry" WHERE "id" = $1`, id) + return entry, err +} + +func (p *PaymentEntryProvider) FetchAll() ([]PaymentEntry, error) { + var entries []PaymentEntry + err := p.DB.Select(&entries, `SELECT * FROM "payment_entry"`) + return entries, err +} + +func (p *PaymentEntryProvider) FetchByOrderId(orderId OrderId) (PaymentEntry, error) { + entry := PaymentEntry{} + err := p.DB.Get(&entry, `SELECT * FROM "payment_entry" WHERE "order_id" = $1`, orderId) + return entry, err +} diff --git a/db/prod/v1_0.sql b/db/prod/v1_0.sql index 956632f..a6a8494 100644 --- a/db/prod/v1_0.sql +++ b/db/prod/v1_0.sql @@ -1 +1,65 @@ -package prod + +CREATE TABLE IF NOT EXISTS "wspay" +( + "id" uuid NOT NULL, + "shop_id" varchar(128) NOT NULL, + "shopping_card_id" varchar(128) NOT NULL, + "total_amount" int NOT NULL, + + "lang" varchar(128) DEFAULT '', + + "customer_first_name" varchar(128) DEFAULT '', + "customer_last_name" varchar(128) DEFAULT '', + "customer_address" varchar(128) DEFAULT '', + "customer_city" varchar(128) DEFAULT '', + "customer_zip" varchar(128) DEFAULT '', + "customer_country" varchar(128) DEFAULT '', + "customer_phone" varchar(128) DEFAULT '', + + "payment_plan" varchar(128) DEFAULT '', + "credit_card_name" varchar(128) DEFAULT '', + "credit_card_number" varchar(128) DEFAULT '', + "payment_method" varchar(128) DEFAULT '', + "currency_code" int DEFAULT 0, + + "date_time" timestamp DEFAULT current_timestamp, + + "eci" varchar(256) DEFAULT '', + "stan" varchar(256) DEFAULT '', + + "success" int DEFAULT 0, + "approval_code" varchar(256) DEFAULT '', + "error_message" varchar(256) DEFAULT '', + "error_codes" varchar(256) DEFAULT '', + + "payment_state" varchar(256) DEFAULT '', + + PRIMARY KEY (id), + CONSTRAINT unique_id UNIQUE ("shopping_card_id") +); + + +CREATE TABLE IF NOT EXISTS "stripe" +( + "id" uuid NOT NULL, + "total_amount" int NOT NULL, + "lang" varchar(128) DEFAULT '', + "payment_intent_id" varchar(256) DEFAULT '', + "payment_state" varchar(256) DEFAULT '', + + PRIMARY KEY (id) +); + + +CREATE TABLE IF NOT EXISTS "viva" +( + "id" uuid NOT NULL, + "order_id" varchar(24) DEFAULT '', + "transaction_id" uuid DEFAULT NULL, + "total_amount" int NOT NULL, + "event_id" varchar(128) DEFAULT '', + "eci" varchar(128) DEFAULT '', + "payment_state" varchar(256) DEFAULT '', + + PRIMARY KEY (id) +); diff --git a/main.go b/main.go index bf4fdbc..1253bb0 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,18 @@ package main import ( - "bytes" "embed" - "encoding/json" "errors" "fmt" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/joho/godotenv" "github.com/stripe/stripe-go/v72" - "github.com/stripe/stripe-go/v72/checkout/session" - "github.com/stripe/stripe-go/v72/paymentintent" "html/template" - "io" "log" "net/http" + "os" + "payment-poc/database" "payment-poc/migration" "payment-poc/state" stripe2 "payment-poc/stripe" @@ -29,32 +26,15 @@ import ( //go:embed db/dev/*.sql var devMigrations embed.FS -var BackendUrl string -var WsPayShopId string -var WsPayShopSecret string - -var VivaMerchantId string -var VivaApiKey string -var VivaSourceCode string -var VivaClientId string -var VivaClientSecret string +type PaymentProvider interface { + CreatePaymentUrl(amount int64) (string, error) + CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) + CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error) +} func init() { godotenv.Load() - BackendUrl = envMustExist("BACKEND_URL") - - WsPayShopId = envMustExist("WSPAY_SHOP_ID") - WsPayShopSecret = envMustExist("WSPAY_SHOP_SECRET") - - VivaMerchantId = envMustExist("VIVA_WALLET_MERCHANT_ID") - VivaApiKey = envMustExist("VIVA_WALLET_API_KEY") - VivaSourceCode = envMustExist("VIVA_WALLET_SOURCE_CODE") - VivaClientId = envMustExist("VIVA_WALLET_CLIENT_ID") - VivaClientSecret = envMustExist("VIVA_WALLET_CLIENT_SECRET") - - stripe.Key = envMustExist("STRIPE_KEY") - log.SetPrefix("") log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) } @@ -69,40 +49,16 @@ func main() { } g := gin.Default() - g.Use(gin.BasicAuth(getAccounts())) + + if !hasProfile("no-auth") { + g.Use(gin.BasicAuth(getAccounts())) + } g.SetFuncMap(template.FuncMap{ - "formatCurrency": func(current int64) string { - return fmt.Sprintf("%d,%02d", current/100, current%100) - }, - "formatCurrency2": func(current int64) string { - return fmt.Sprintf("%d.%02d", current/100, current%100) - }, - "formatState": func(stt state.PaymentState) string { - switch stt { - case state.StateCanceled: - return "Otkazano" - case state.StateVoided: - return "Otkazano sa strane administratora" - case state.StateAccepted: - return "Prihvačeno" - case state.StateError: - return "Greška" - case state.StateInitialized: - return "Inicijalna izrada" - case state.StateCanceledInitialization: - return "Otkazano tijekom izrade" - case state.StateCompleted: - return "Završeno" - } - return "nepoznato stanje '" + string(stt) + "'" - }, - "omitempty": func(value string) string { - if value == "" { - return "-" - } - return value - }, + "formatCurrency": formatCurrency, + "decimalCurrency": decimalCurrency, + "formatState": formatState, + "omitempty": omitempty, }) g.NoRoute(func(c *gin.Context) { @@ -112,46 +68,151 @@ func main() { c.JSON(http.StatusMethodNotAllowed, gin.H{"message": "no action on given method", "created": time.Now()}) }) - wspayService := wspay.Service{ - DB: client, - } - stripeService := stripe2.Service{ - DB: client, - } - vivaService := viva.Service{ - DB: client, - ClientId: VivaClientId, - ClientSecret: VivaClientSecret, - SourceCode: VivaSourceCode, - MerchantId: VivaMerchantId, - ApiKey: VivaApiKey, - } + backendUrl := envMustExist("BACKEND_URL") + + paymentGateways := map[state.PaymentGateway]PaymentProvider{} + entryProvider := database.PaymentEntryProvider{DB: client} g.LoadHTMLGlob("./templates/*.gohtml") - g.GET("/", func(c *gin.Context) { - wspayEntries, _ := wspayService.FetchAll() - stripeEntries, _ := stripeService.FetchAll() - vivaEntries, _ := vivaService.FetchAll() - c.HTML(200, "index.gohtml", gin.H{"WsPay": wspayEntries, "Stripe": stripeEntries, "Viva": vivaEntries}) - }) + if hasProfile(string(state.GatewayWsPay)) { + wspayService := wspay.Service{ + Provider: &entryProvider, + ShopId: envMustExist("WSPAY_SHOP_ID"), + ShopSecret: envMustExist("WSPAY_SHOP_SECRET"), + BackendUrl: backendUrl, + } + setupWsPayEndpoints(g.Group("wspay"), &wspayService) + paymentGateways[state.GatewayWsPay] = &wspayService + } + if hasProfile(string(state.GatewayStripe)) { + stripeService := stripe2.Service{ + Provider: &entryProvider, + ApiKey: envMustExist("STRIPE_KEY"), + BackendUrl: backendUrl, + } + setupStripeEndpoints(g.Group("stripe"), &stripeService) + paymentGateways[state.GatewayStripe] = &stripeService + stripe.Key = envMustExist("STRIPE_KEY") + } + if hasProfile(string(state.GatewayVivaWallet)) { + vivaService := viva.Service{ + Provider: &entryProvider, + 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"), + } + setupVivaEndpoints(g.Group("viva"), &vivaService) + paymentGateways[state.GatewayVivaWallet] = &vivaService + } + 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}) + var gateways []state.PaymentGateway + for key := range paymentGateways { + gateways = append(gateways, key) + } + c.HTML(200, "methods.gohtml", gin.H{"Amount": amount, "Gateways": gateways}) + }) + g.GET("/: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 + } + if url, err := paymentGateway.CreatePaymentUrl(amount); err == nil { + 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.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 + } + entry, err = paymentGateway.CompleteTransaction(entry, amount) + if err != nil { + entryProvider.UpdateEntry(entry) + 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 { + entry, err = paymentGateway.CancelTransaction(entry) + if err != nil { + entryProvider.UpdateEntry(entry) + 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 + } + } }) - setupWsPayEndpoints(g.Group("wspay"), wspayService) - setupStripeEndpoints(g.Group("stripe"), stripeService) - setupVivaEndpoints(g.Group("viva"), vivaService) - log.Fatal(http.ListenAndServe(":5281", g)) } +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 + } + return "", errors.New("unknown gateway: " + gateway) +} + func getAccounts() gin.Accounts { auth := strings.Split(envMustExist("AUTH"), ":") return gin.Accounts{auth[0]: auth[1]} @@ -165,595 +226,137 @@ func parseDateTime(dateTime string) time.Time { return t } -func setupVivaEndpoints(g *gin.RouterGroup, vivaService viva.Service) { - g.GET("", func(c *gin.Context) { - amount, err := strconv.ParseFloat(c.Query("amount"), 64) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - - entry, err := vivaService.CreateEntry(int64(amount * 100)) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - log.Printf("Created initial viva entry (ammount=%d)", amount) - - entry, err = vivaService.CreatePaymentOrder(entry) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - - vivaService.Update(entry) - c.Redirect(http.StatusSeeOther, "https://demo.vivapayments.com/web/checkout?ref="+entry.OrderId) - }) - - g.POST("complete/:id", func(c *gin.Context) { - id := uuid.MustParse(c.Param("id")) - amount, err := strconv.ParseFloat(c.PostForm("amount"), 64) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - entry, err := vivaService.FetchById(id) - if err != nil { - c.AbortWithError(http.StatusNotFound, err) - return - } - if int64(amount*100) > entry.TotalAmount || int64(amount*100) < 1 { - c.AbortWithError(http.StatusBadRequest, err) - return - } - if entry.State == state.StateInitialized || entry.State == state.StateAccepted { - entry, err = vivaService.CompleteTransaction(entry, int64(amount*100)) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - vivaService.Update(entry) - } - - c.Redirect(http.StatusSeeOther, "/viva/info/"+id.String()) - }) - - g.POST("cancel/:id", func(c *gin.Context) { - id := uuid.MustParse(c.Param("id")) - entry, err := vivaService.FetchById(id) - if err != nil { - c.AbortWithError(http.StatusNotFound, err) - return - } - if entry.State == state.StateInitialized || entry.State == state.StateAccepted { - entry, err = vivaService.CancelTransaction(entry) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - vivaService.Update(entry) - } - - c.Redirect(http.StatusSeeOther, "/viva/info/"+id.String()) - }) +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 setupVivaEndpoints(g *gin.RouterGroup, vivaService *viva.Service) { g.GET("success", func(c *gin.Context) { - transactionId := uuid.MustParse(c.Query("t")) - orderId := viva.OrderId(c.Query("s")) - lang := c.Query("lang") - eventId := c.Query("eventId") - eci := c.Query("eci") - - log.Printf("Received success response for viva payment %s", orderId) - entry, err := vivaService.FetchByOrderId(orderId) + url, err := vivaService.HandleResponse(c, state.StateAccepted) if err != nil { - log.Printf("Couldn't find payment info for viva payment %s", orderId) c.AbortWithError(http.StatusInternalServerError, err) return } - - entry.State = state.StateAccepted - entry.ECI = eci - entry.Lang = lang - entry.EventId = eventId - entry.TransactionId = transactionId - - if err := vivaService.Update(entry); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - - log.Printf("Viva payment %s received correctly, returning redirect", entry.OrderId) - c.Redirect(http.StatusTemporaryRedirect, "/") + c.Redirect(http.StatusSeeOther, url) }) g.GET("error", func(c *gin.Context) { - transactionId := uuid.MustParse(c.Query("t")) - orderId := viva.OrderId(c.Query("s")) - lang := c.Query("lang") - eventId := c.Query("eventId") - eci := c.Query("eci") - - log.Printf("Received error response for viva payment %s", orderId) - entry, err := vivaService.FetchByOrderId(orderId) + url, err := vivaService.HandleResponse(c, state.StateError) if err != nil { - log.Printf("Couldn't find payment info for viva payment %s", orderId) c.AbortWithError(http.StatusInternalServerError, err) return } - - entry.State = state.StateAccepted - entry.ECI = eci - entry.Lang = lang - entry.EventId = eventId - entry.TransactionId = transactionId - - if err := vivaService.Update(entry); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - - log.Printf("Viva payment %s received correctly, returning redirect", entry.OrderId) - c.Redirect(http.StatusTemporaryRedirect, "/") - }) - g.GET("info/:id", func(c *gin.Context) { - id := uuid.MustParse(c.Param("id")) - entry, err := vivaService.FetchById(id) - if err != nil { - c.AbortWithError(http.StatusNotFound, err) - return - } - c.HTML(200, "viva_info.gohtml", gin.H{"Entry": entry}) + c.Redirect(http.StatusSeeOther, url) }) } -func setupStripeEndpoints(g *gin.RouterGroup, stripeService stripe2.Service) { - g.GET("", func(c *gin.Context) { - amount, err := strconv.ParseFloat(c.Query("amount"), 64) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - - entry, err := stripeService.CreateEntry(int64(amount * 100)) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - log.Printf("Created initial stripe entry (ammount=%d)", amount) - - currency := string(stripe.CurrencyEUR) - productName := "Example product" - productDescription := "Simple example product" - - params := &stripe.CheckoutSessionParams{ - LineItems: []*stripe.CheckoutSessionLineItemParams{ - { - PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{ - Currency: ¤cy, - ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{ - Name: &productName, - Description: &productDescription, - }, - UnitAmount: &entry.TotalAmount, - }, - Quantity: stripe.Int64(1), - }, - }, - Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), - PaymentIntentData: &stripe.CheckoutSessionPaymentIntentDataParams{ - CaptureMethod: stripe.String("manual"), - }, - SuccessURL: stripe.String(BackendUrl + "/stripe/success?token=" + entry.Id.String()), - CancelURL: stripe.String(BackendUrl + "/stripe/cancel?token=" + entry.Id.String()), - } - result, err := session.New(params) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - entry.PaymentIntentId = result.PaymentIntent.ID - stripeService.Update(entry) - - c.Redirect(http.StatusSeeOther, result.URL) - }) - - g.POST("complete/:id", func(c *gin.Context) { - id := uuid.MustParse(c.Param("id")) - amount, err := strconv.ParseFloat(c.PostForm("amount"), 64) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - entry, err := stripeService.FetchById(id) - if err != nil { - c.AbortWithError(http.StatusNotFound, err) - return - } - if int64(amount*100) > entry.TotalAmount || int64(amount*100) < 1 { - c.AbortWithError(http.StatusBadRequest, err) - return - } - if entry.State == state.StateInitialized || entry.State == state.StateAccepted { - params := &stripe.PaymentIntentCaptureParams{ - AmountToCapture: stripe.Int64(int64(amount * 100)), - } - pi, err := paymentintent.Capture(entry.PaymentIntentId, params) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - log.Printf("received state on completion: %v", pi.Status) - if pi.Status == stripe.PaymentIntentStatusSucceeded || pi.Status == stripe.PaymentIntentStatusProcessing { - entry.TotalAmount = pi.Amount - entry.State = state.StateCompleted - stripeService.Update(entry) - } - } - - c.Redirect(http.StatusSeeOther, "/stripe/info/"+id.String()) - }) - - g.POST("cancel/:id", func(c *gin.Context) { - id := uuid.MustParse(c.Param("id")) - entry, err := stripeService.FetchById(id) - if err != nil { - c.AbortWithError(http.StatusNotFound, err) - return - } - if entry.State == state.StateInitialized || entry.State == state.StateAccepted { - params := &stripe.PaymentIntentCancelParams{} - pi, err := paymentintent.Cancel(entry.PaymentIntentId, params) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - log.Printf("received state on completion: %v", pi.Status) - if pi.Status == stripe.PaymentIntentStatusCanceled { - entry.State = state.StateCanceled - stripeService.Update(entry) - } - } - - c.Redirect(http.StatusSeeOther, "/stripe/info/"+id.String()) - }) +func setupStripeEndpoints(g *gin.RouterGroup, stripeService *stripe2.Service) { g.GET("success", func(c *gin.Context) { - id := uuid.MustParse(c.Query("token")) - - log.Printf("Received success response for stripe payment %s", id) - entry, err := stripeService.FetchById(id) + url, err := stripeService.HandleResponse(c, state.StateAccepted) if err != nil { - log.Printf("Couldn't find payment info for stripe payment %s", id) c.AbortWithError(http.StatusInternalServerError, err) return } - - entry.State = state.StateAccepted - - if err := stripeService.Update(entry); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - - log.Printf("Stripe payment %s received correctly, returning redirect", id) - c.Redirect(http.StatusTemporaryRedirect, "/") + c.Redirect(http.StatusSeeOther, url) }) g.GET("error", func(c *gin.Context) { - id := uuid.MustParse(c.Query("token")) - - log.Printf("Received error response for stripe payment %s", id) - entry, err := stripeService.FetchById(id) + url, err := stripeService.HandleResponse(c, state.StateError) if err != nil { - log.Printf("Couldn't find payment info for stripe payment %s", id) c.AbortWithError(http.StatusInternalServerError, err) return } - - entry.State = state.StateError - - if err := stripeService.Update(entry); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - log.Printf("Stripe payment %s received correctly, returning redirect", id) - c.Redirect(http.StatusTemporaryRedirect, "/") - }) - g.GET("info/:id", func(c *gin.Context) { - id := uuid.MustParse(c.Param("id")) - entry, err := stripeService.FetchById(id) - if err != nil { - c.AbortWithError(http.StatusNotFound, err) - return - } - c.HTML(200, "stripe_info.gohtml", gin.H{"Entry": entry}) + c.Redirect(http.StatusSeeOther, url) }) } -func setupWsPayEndpoints(g *gin.RouterGroup, wspayService wspay.Service) { - g.GET("", func(c *gin.Context) { - amount, err := strconv.ParseFloat(c.Query("amount"), 64) +func setupWsPayEndpoints(g *gin.RouterGroup, wspayService *wspay.Service) { + g.GET("/initialize/:id", func(c *gin.Context) { + entry, err := wspayService.Provider.FetchById(uuid.MustParse(c.Param("id"))) if err != nil { + c.AbortWithError(http.StatusNotFound, err) + return + } + if entry.State != state.StateInitialized { c.AbortWithError(http.StatusBadRequest, err) return } - entry, err := wspayService.CreateEntry(WsPayShopId, int64(amount*100)) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - - log.Printf("Created initial wspay form (ammount=%d)", amount) - - form := wspay.WsPayForm{ - ShopID: WsPayShopId, - ShoppingCartID: entry.ShoppingCartID, - Version: "2.0", - TotalAmount: entry.TotalAmount, - ReturnURL: BackendUrl + "/wspay/success", - ReturnErrorURL: BackendUrl + "/wspay/error", - CancelURL: BackendUrl + "/wspay/cancel", - Signature: wspay.CalculateFormSignature(WsPayShopId, WsPayShopSecret, entry.ShoppingCartID, entry.TotalAmount), - } + form := wspayService.InitializePayment(entry) c.HTML(200, "wspay.gohtml", gin.H{"Action": wspay.AuthorisationForm, "Form": form}) }) - g.POST("complete/:id", func(c *gin.Context) { - id := uuid.MustParse(c.Param("id")) - amount, err := strconv.ParseFloat(c.PostForm("amount"), 64) - if err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - entry, err := wspayService.FetchById(id) - if err != nil { - c.AbortWithError(http.StatusNotFound, err) - return - } - if int64(amount*100) > entry.TotalAmount || int64(amount*100) < 1 { - c.AbortWithError(http.StatusBadRequest, err) - return - } - if entry.State == state.StateAccepted { - var request = wspay.WsPayCompletionRequest{ - Version: "2.0", - WsPayOrderId: entry.ShoppingCartID, - ShopId: entry.ShopID, - ApprovalCode: entry.ApprovalCode, - STAN: entry.STAN, - Amount: int64(amount * 100), - Signature: wspay.CalculateCompletionSignature(WsPayShopId, WsPayShopSecret, entry.ShoppingCartID, entry.STAN, entry.ApprovalCode, int64(amount*100)), - } - content, _ := json.Marshal(&request) - response, err := http.Post("https://test.wspay.biz/api/services/completion", "application/json", bytes.NewBuffer(content)) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - if response.StatusCode == http.StatusOK { - transactionResponse := wspay.WsPayCompletionResponse{} - content, err := io.ReadAll(response.Body) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - if err := json.Unmarshal(content, &transactionResponse); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } else { - log.Printf("Received transaction response: success=%s, errorMessage=%s, approvalCode=%s", - transactionResponse.ActionSuccess, transactionResponse.ErrorMessage, transactionResponse.ApprovalCode, - ) - if wspay.CompareCompletionReturnSignature(transactionResponse.Signature, WsPayShopId, WsPayShopSecret, entry.ShoppingCartID, entry.STAN, transactionResponse.ActionSuccess, transactionResponse.ApprovalCode) != nil { - entry.TotalAmount = int64(amount * 100) - entry.State = state.StateCompleted - wspayService.Update(entry) - } else { - c.AbortWithError(http.StatusInternalServerError, errors.New("received invalid signature")) - return - } - } - } else { - c.AbortWithError(http.StatusInternalServerError, errors.New("received wrong status, expected 200 received "+strconv.FormatInt(int64(response.StatusCode), 10))) - return - } - } - - c.Redirect(http.StatusSeeOther, "/wspay/info/"+id.String()) - }) - g.POST("cancel/:id", func(c *gin.Context) { - id := uuid.MustParse(c.Param("id")) - entry, err := wspayService.FetchById(id) - if err != nil { - c.AbortWithError(http.StatusNotFound, err) - return - } - if entry.State == state.StateAccepted { - var request = wspay.WsPayCompletionRequest{ - Version: "2.0", - WsPayOrderId: entry.ShoppingCartID, - ShopId: entry.ShopID, - ApprovalCode: entry.ApprovalCode, - STAN: entry.STAN, - Amount: entry.TotalAmount, - Signature: wspay.CalculateCompletionSignature(WsPayShopId, WsPayShopSecret, entry.ShoppingCartID, entry.STAN, entry.ApprovalCode, entry.TotalAmount), - } - - content, _ := json.Marshal(&request) - response, err := http.Post("https://test.wspay.biz/api/services/void", "application/json", bytes.NewBuffer(content)) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - if response.StatusCode == http.StatusOK { - transactionResponse := wspay.WsPayCompletionResponse{} - content, err := io.ReadAll(response.Body) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - if err := json.Unmarshal(content, &transactionResponse); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } else { - log.Printf("Received transaction response: success=%s, errorMessage=%s, approvalCode=%s", - transactionResponse.ActionSuccess, transactionResponse.ErrorMessage, transactionResponse.ApprovalCode, - ) - if wspay.CompareCompletionReturnSignature(transactionResponse.Signature, WsPayShopId, WsPayShopSecret, entry.ShoppingCartID, entry.STAN, transactionResponse.ActionSuccess, transactionResponse.ApprovalCode) != nil { - entry.State = state.StateCanceled - wspayService.Update(entry) - } else { - c.AbortWithError(http.StatusInternalServerError, errors.New("received invalid signature")) - return - } - } - } else { - c.AbortWithError(http.StatusInternalServerError, errors.New("received wrong status, expected 200 received "+strconv.FormatInt(int64(response.StatusCode), 10))) - return - } - } - - c.Redirect(http.StatusSeeOther, "/wspay/info/"+id.String()) - }) g.GET("success", func(c *gin.Context) { - response := wspay.WsPayFormReturn{} - if err := c.ShouldBind(&response); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - log.Printf("Received success response for transaction %s", response.ShoppingCartID) - entry, err := wspayService.FetchByShoppingCartID(response.ShoppingCartID) + url, err := wspayService.HandleSuccessResponse(c) if err != nil { - log.Printf("Couldn't find payment info for transaction %s", response.ShoppingCartID) c.AbortWithError(http.StatusInternalServerError, err) return } - - if err := wspay.CompareFormReturnSignature(response.Signature, WsPayShopId, WsPayShopSecret, response.ShoppingCartID, response.Success, response.ApprovalCode); err != nil { - log.Printf("Invalid signature for transaction %s", response.ShoppingCartID) - c.AbortWithError(http.StatusBadRequest, err) - return - } - - entry.Lang = response.Lang - entry.CustomerFirstName = response.CustomerFirstName - entry.CustomerLastName = response.CustomerSurname - entry.CustomerAddress = response.CustomerAddress - entry.CustomerCity = response.CustomerCity - entry.CustomerZIP = response.CustomerZIP - entry.CustomerCountry = response.CustomerCountry - entry.CustomerPhone = response.CustomerPhone - - entry.PaymentPlan = response.PaymentPlan - entry.CreditCardNumber = response.CreditCardNumber - entry.DateTime = parseDateTime(response.DateTime) - - entry.ECI = response.ECI - entry.STAN = response.STAN - - entry.Success = response.Success - entry.ApprovalCode = response.ApprovalCode - entry.ErrorMessage = response.ErrorMessage - - entry.State = state.StateAccepted - - if err := wspayService.Update(entry); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - - if c.Query("iframe") != "" { - log.Printf("Transaction %s received correctly, returning iframe response", response.ShoppingCartID) - c.HTML(200, "iframe_handler.gohtml", gin.H{}) - } else { - log.Printf("Transaction %s received correctly, returning redirect", response.ShoppingCartID) - c.Redirect(http.StatusTemporaryRedirect, "/") - } + c.Redirect(http.StatusSeeOther, url) }) g.GET("error", func(c *gin.Context) { - response := wspay.WsPayFormError{} - if err := c.ShouldBind(&response); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - log.Printf("Received error response for transaction %s", response.ShoppingCartID) - entry, err := wspayService.FetchByShoppingCartID(response.ShoppingCartID) + url, err := wspayService.HandleErrorResponse(c, state.StateError) if err != nil { - log.Printf("Couldn't find payment info for transaction %s", response.ShoppingCartID) c.AbortWithError(http.StatusInternalServerError, err) return } - - entry.Lang = response.Lang - entry.CustomerFirstName = response.CustomerFirstName - entry.CustomerLastName = response.CustomerSurname - entry.CustomerAddress = response.CustomerAddress - entry.CustomerCity = response.CustomerCity - entry.CustomerZIP = response.CustomerZIP - entry.CustomerCountry = response.CustomerCountry - entry.CustomerPhone = response.CustomerPhone - - entry.PaymentPlan = response.PaymentPlan - entry.DateTime = parseDateTime(response.DateTime) - - entry.ECI = response.ECI - - entry.Success = response.Success - entry.ApprovalCode = response.ApprovalCode - entry.ErrorMessage = response.ErrorMessage - entry.ErrorCodes = response.ErrorCodes - - entry.State = state.StateError - - if err := wspayService.Update(entry); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - - if c.Query("iframe") != "" { - log.Printf("Transaction %s received correctly, returning iframe response", response.ShoppingCartID) - c.HTML(200, "iframe_handler.gohtml", gin.H{}) - } else { - log.Printf("Transaction %s received correctly, returning redirect", response.ShoppingCartID) - c.Redirect(http.StatusTemporaryRedirect, "/") - } - }) - g.GET("info/:id", func(c *gin.Context) { - id := uuid.MustParse(c.Param("id")) - entry, err := wspayService.FetchById(id) - if err != nil { - c.AbortWithError(http.StatusNotFound, err) - return - } - c.HTML(200, "wspay_info.gohtml", gin.H{"Entry": entry}) + c.Redirect(http.StatusSeeOther, url) }) g.GET("cancel", func(c *gin.Context) { - response := wspay.WsPayFormCancel{} - if err := c.ShouldBind(&response); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - log.Printf("Received error response for transaction %s", response.ShoppingCartID) - entry, err := wspayService.FetchByShoppingCartID(response.ShoppingCartID) + url, err := wspayService.HandleErrorResponse(c, state.StateCanceled) if err != nil { - log.Printf("Couldn't find payment info for transaction %s", response.ShoppingCartID) c.AbortWithError(http.StatusInternalServerError, err) return } - entry.State = state.StateCanceledInitialization - - if err := wspayService.Update(entry); err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - - if c.Query("iframe") != "" { - log.Printf("Transaction %s received correctly, returning iframe response", response.ShoppingCartID) - c.HTML(200, "iframe_handler.gohtml", gin.H{}) - } else { - log.Printf("Transaction %s received correctly, returning redirect", response.ShoppingCartID) - c.Redirect(http.StatusTemporaryRedirect, "/") - } + c.Redirect(http.StatusSeeOther, url) }) } + +func hasProfile(profile string) bool { + profiles := strings.Split(os.Getenv("PROFILE"), ",") + for _, p := range profiles { + if profile == strings.TrimSpace(p) { + return true + } + } + return false +} + +func formatState(stt state.PaymentState) string { + switch stt { + case state.StateCanceled: + return "Otkazano" + case state.StateVoided: + return "Otkazano sa strane administratora" + case state.StateAccepted: + return "Prihvačeno" + case state.StateError: + return "Greška" + case state.StateInitialized: + return "Inicijalna izrada" + case state.StateCanceledInitialization: + return "Otkazano tijekom izrade" + case state.StateCompleted: + return "Završeno" + } + return "nepoznato stanje '" + string(stt) + "'" +} + +func formatCurrency(current int64) string { + return fmt.Sprintf("%d,%02d", current/100, current%100) +} + +func decimalCurrency(current int64) string { + return fmt.Sprintf("%d,%02d", current/100, current%100) +} + +func omitempty(value string) string { + if value == "" { + return "-" + } + return value +} diff --git a/state/model.go b/state/model.go index 6d5bd4c..b3ff9b4 100644 --- a/state/model.go +++ b/state/model.go @@ -16,3 +16,11 @@ const ( StateVoided PaymentState = "voided" StateCanceled PaymentState = "canceled" ) + +type PaymentGateway string + +const ( + GatewayWsPay PaymentGateway = "wspay" + GatewayStripe PaymentGateway = "stripe" + GatewayVivaWallet PaymentGateway = "viva-wallet" +) diff --git a/stripe/service.go b/stripe/service.go index 9802afb..7c9aeb5 100644 --- a/stripe/service.go +++ b/stripe/service.go @@ -1,46 +1,114 @@ package stripe import ( + "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/jmoiron/sqlx" + "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" ) type Service struct { - DB *sqlx.DB + Provider *database.PaymentEntryProvider + ApiKey string + BackendUrl string } -func (s *Service) CreateEntry(totalAmount int64) (StripeDb, error) { - id := uuid.Must(uuid.NewRandom()) - entry := StripeDb{ - Id: id, - TotalAmount: totalAmount, +func (s *Service) CreatePaymentUrl(amount int64) (url string, err error) { + entry, err := s.Provider.CreateEntry(database.PaymentEntry{ + Gateway: state.GatewayVivaWallet, State: state.StateInitialized, - } - _, err := s.DB.Exec(`INSERT INTO "stripe" ("id", "total_amount", "payment_state") VALUES ($1, $2, $3)`, - &entry.Id, &entry.TotalAmount, &entry.State, - ) + TotalAmount: amount, + }) if err != nil { - return StripeDb{}, err + return "", err } - return s.FetchById(id) + entry, url, err = s.InitializePayment(entry) + if err != nil { + return "", err + } + entry, err = s.Provider.UpdateEntry(entry) + if err != nil { + return "", err + } + return url, nil } -func (s *Service) FetchAll() ([]StripeDb, error) { - var entries []StripeDb - err := s.DB.Select(&entries, `SELECT * FROM "stripe"`) - return entries, err +func (s *Service) InitializePayment(entry database.PaymentEntry) (database.PaymentEntry, string, error) { + + currency := string(stripe.CurrencyEUR) + productName := "Example product" + productDescription := "Simple example product" + + params := &stripe.CheckoutSessionParams{ + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{ + Currency: ¤cy, + ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{ + Name: &productName, + Description: &productDescription, + }, + UnitAmount: &entry.TotalAmount, + }, + Quantity: stripe.Int64(1), + }, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + PaymentIntentData: &stripe.CheckoutSessionPaymentIntentDataParams{ + CaptureMethod: stripe.String("manual"), + }, + SuccessURL: stripe.String(s.BackendUrl + "/stripe/success?token=" + entry.Id.String()), + CancelURL: stripe.String(s.BackendUrl + "/stripe/cancel?token=" + entry.Id.String()), + } + result, err := session.New(params) + if err != nil { + return database.PaymentEntry{}, "", err + } + entry.PaymentIntentId = &result.PaymentIntent.ID + + return entry, result.URL, nil } -func (s *Service) FetchById(id uuid.UUID) (StripeDb, error) { - entry := StripeDb{} - err := s.DB.Get(&entry, `SELECT * FROM "stripe" WHERE "id" = $1`, id) - return entry, err +func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) { + params := &stripe.PaymentIntentCaptureParams{ + AmountToCapture: stripe.Int64(amount), + } + pi, err := paymentintent.Capture(*entry.PaymentIntentId, params) + if err != nil { + return database.PaymentEntry{}, err + } + log.Printf("received state on completion: %v", pi.Status) + if pi.Status == stripe.PaymentIntentStatusSucceeded || pi.Status == stripe.PaymentIntentStatusProcessing { + entry.TotalAmount = pi.Amount + entry.State = state.StateCompleted + } + return entry, nil } -func (s *Service) Update(entry StripeDb) error { - _, err := s.DB.Exec(`UPDATE "stripe" set "payment_intent_id" = $2, "payment_state" = $3 WHERE "id" = $1`, - &entry.Id, &entry.PaymentIntentId, &entry.State, - ) - return err +func (s *Service) CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error) { + params := &stripe.PaymentIntentCancelParams{} + pi, err := paymentintent.Cancel(*entry.PaymentIntentId, params) + if err != nil { + return database.PaymentEntry{}, err + } + log.Printf("received state on completion: %v", pi.Status) + if pi.Status == stripe.PaymentIntentStatusCanceled { + entry.State = state.StateCanceled + } + return entry, nil +} + +func (s *Service) HandleResponse(c *gin.Context, paymentState state.PaymentState) (string, error) { + id := uuid.MustParse(c.Query("token")) + entry, err := s.Provider.FetchById(id) + if err != nil { + return "", err + } + entry.State = paymentState + s.Provider.UpdateEntry(entry) + return "/entries/" + entry.Id.String(), nil } diff --git a/templates/stripe_info.gohtml b/templates/stripe_info.gohtml index 6132644..68e08d3 100644 --- a/templates/stripe_info.gohtml +++ b/templates/stripe_info.gohtml @@ -30,7 +30,7 @@
diff --git a/templates/viva_info.gohtml b/templates/viva_info.gohtml index 22c66fa..090f134 100644 --- a/templates/viva_info.gohtml +++ b/templates/viva_info.gohtml @@ -32,7 +32,7 @@ diff --git a/templates/wspay_info.gohtml b/templates/wspay_info.gohtml index f20f233..8f01791 100644 --- a/templates/wspay_info.gohtml +++ b/templates/wspay_info.gohtml @@ -50,7 +50,7 @@ diff --git a/viva/model.go b/viva/model.go deleted file mode 100644 index f2d6e35..0000000 --- a/viva/model.go +++ /dev/null @@ -1,26 +0,0 @@ -package viva - -import ( - "github.com/google/uuid" - "payment-poc/state" - "time" -) - -const VivaUrl = "https://demo-api.vivapayments.com" - -type VivaDb struct { - Id uuid.UUID `db:"id"` - OrderId string `db:"order_id"` - TransactionId uuid.UUID `db:"transaction_id"` - TotalAmount int64 `db:"total_amount"` - Lang string `db:"lang"` - - EventId string `db:"event_id"` - ECI string `db:"eci"` - - DateTime time.Time `db:"date_time"` - - // transaction response - - State state.PaymentState `db:"payment_state"` -} diff --git a/viva/service.go b/viva/service.go index a6aa129..98c4fbb 100644 --- a/viva/service.go +++ b/viva/service.go @@ -5,43 +5,58 @@ import ( "encoding/base64" "encoding/json" "errors" + "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/jmoiron/sqlx" "io" "log" "net/http" "net/url" + "payment-poc/database" "payment-poc/state" "strconv" - "strings" "time" ) type Service struct { - DB *sqlx.DB - Token string - Expiration time.Time + Provider *database.PaymentEntryProvider ClientId string ClientSecret string SourceCode string MerchantId string ApiKey string + + token string + expiration time.Time } -func (s *Service) OAuthToken() (string, error) { - if s.Token != "" && s.Expiration.After(time.Now()) { - return s.Token, nil - } - return s.fetchOAuthToken() -} - -func (s *Service) CreatePaymentOrder(entry VivaDb) (VivaDb, error) { - token, err := s.OAuthToken() +func (s *Service) CreatePaymentUrl(amount int64) (url string, err error) { + entry, err := s.Provider.CreateEntry(database.PaymentEntry{ + Gateway: state.GatewayVivaWallet, + State: state.StateInitialized, + TotalAmount: amount, + }) if err != nil { - return VivaDb{}, err + return "", err } - orderRequest := VivaOrderRequest{ + entry, err = s.InitializePayment(entry) + if err != nil { + return "", err + } + entry, err = s.Provider.UpdateEntry(entry) + if err != nil { + return "", err + } + return "https://demo.vivapayments.com/web/checkout?ref=" + string(*entry.OrderId), nil +} + +func (s *Service) InitializePayment(entry database.PaymentEntry) (database.PaymentEntry, error) { + token, err := s.oAuthToken() + if err != nil { + return database.PaymentEntry{}, err + } + + request := OrderRequest{ Amount: entry.TotalAmount, Description: "Example payment", MerchantDescription: "Example payment", @@ -50,186 +65,173 @@ func (s *Service) CreatePaymentOrder(entry VivaDb) (VivaDb, error) { Source: s.SourceCode, } - content, err := json.Marshal(&orderRequest) - if err != nil { - return VivaDb{}, err - } - - request, err := http.NewRequest("POST", "https://demo-api.vivapayments.com/checkout/v2/orders", bytes.NewReader(content)) - request.Header.Add("authorization", "Bearer "+token) - request.Header.Add("content-type", "application/json") - - response, err := http.DefaultClient.Do(request) - if err != nil { - return VivaDb{}, err - } - if response.StatusCode == http.StatusOK { - orderResponse := VivaOrderResponse{} - content, err := io.ReadAll(response.Body) - if err != nil { - return VivaDb{}, err - } - if err := json.Unmarshal(content, &orderResponse); err != nil { - return VivaDb{}, err - } else { - entry.OrderId = string(orderResponse.OrderId) - return entry, nil - } - } else { - return VivaDb{}, errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(response.StatusCode), 10)) - } -} - -func (s *Service) CreateEntry(totalAmount int64) (VivaDb, error) { - id := uuid.Must(uuid.NewRandom()) - entry := VivaDb{ - Id: id, - TotalAmount: totalAmount, - State: state.StateInitialized, - } - _, err := s.DB.Exec(`INSERT INTO "viva" ("id", "total_amount", "payment_state") VALUES ($1, $2, $3)`, - &entry.Id, &entry.TotalAmount, &entry.State, + httpResponse, err := createRequest( + "POST", + "https://demo-api.vivapayments.com/checkout/v2/orders", + map[string]string{"authorization": "Bearer " + token, "content-type": "application/json"}, + toJson(request), ) if err != nil { - return VivaDb{}, err + return database.PaymentEntry{}, err } - return s.FetchById(id) -} -func (s *Service) FetchAll() ([]VivaDb, error) { - var entries []VivaDb - err := s.DB.Select(&entries, `SELECT * FROM "viva"`) - return entries, err -} - -func (s *Service) FetchById(id uuid.UUID) (VivaDb, error) { - entry := VivaDb{} - err := s.DB.Get(&entry, `SELECT * FROM "viva" WHERE "id" = $1`, id) - return entry, err -} - -func (s *Service) FetchByOrderId(id OrderId) (VivaDb, error) { - entry := VivaDb{} - err := s.DB.Get(&entry, `SELECT * FROM "viva" WHERE "order_id" = $1`, string(id)) - return entry, err -} - -func (s *Service) Update(entry VivaDb) error { - _, err := s.DB.Exec(`UPDATE "viva" set "order_id" = $2, "transaction_id" = $3, "payment_state" = $4 WHERE "id" = $1`, - &entry.Id, &entry.OrderId, &entry.TransactionId, &entry.State, - ) - return err -} - -func (s *Service) fetchOAuthToken() (string, error) { - - form := url.Values{ - "grant_type": []string{"client_credentials"}, - } - request, err := http.NewRequest("POST", "https://demo-accounts.vivapayments.com/connect/token", strings.NewReader(form.Encode())) + var response OrderResponse + err = readResponse(httpResponse, &response) if err != nil { - return "", err + return database.PaymentEntry{}, err } - request.Header.Add("content-type", "application/x-www-form-urlencoded") - request.SetBasicAuth(s.ClientId, s.ClientSecret) - - response, err := http.DefaultClient.Do(request) - if err != nil { - return "", err - } - if response.StatusCode == http.StatusOK { - oauthObject := VivaOAuthResponse{} - content, err := io.ReadAll(response.Body) - if err != nil { - return "", err - } - if err := json.Unmarshal(content, &oauthObject); err != nil { - return "", err - } else { - s.Token = oauthObject.AccessToken - s.Expiration = time.Now().Add(time.Duration(oauthObject.ExpiresIn) * time.Second) - } - } else { - return "", errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(response.StatusCode), 10)) - } - return s.Token, nil + entry.OrderId = &response.OrderId + return entry, nil } -func (s *Service) CompleteTransaction(entry VivaDb, amount int64) (VivaDb, error) { - completionRequest := VivaTransactionCompleteRequest{ +func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) { + completionRequest := TransactionCompleteRequest{ Amount: amount, CustomerDescription: "Example transaction", } - content, err := json.Marshal(&completionRequest) + httpResponse, err := createRequest( + "POST", + "https://demo.vivapayments.com/api/transactions/"+entry.TransactionId.String(), + map[string]string{"authorization": "Bearer " + s.basicAuth(), + "content-type": "application/json", + }, + toJson(completionRequest), + ) if err != nil { - return VivaDb{}, err + return database.PaymentEntry{}, err } - request, err := http.NewRequest("POST", "https://demo.vivapayments.com/api/transactions/"+entry.TransactionId.String(), bytes.NewReader(content)) - request.Header.Add("authorization", "Bearer "+s.BasicAuth()) - request.Header.Add("content-type", "application/json") - - response, err := http.DefaultClient.Do(request) + var response TransactionResponse + err = readResponse(httpResponse, &response) if err != nil { - return VivaDb{}, err + return database.PaymentEntry{}, err } - if response.StatusCode == http.StatusOK { - transactionResponse := VivaTransactionResponse{} - content, err := io.ReadAll(response.Body) - if err != nil { - return VivaDb{}, err - } - if err := json.Unmarshal(content, &transactionResponse); err != nil { - return VivaDb{}, err - } else { - log.Printf("Received transaction response: success=%v, eventId=%d, status=%s, amount=%f, errorCode=%d, errorText=%s", - transactionResponse.Success, transactionResponse.EventId, transactionResponse.StatusId, transactionResponse.Amount, transactionResponse.ErrorCode, transactionResponse.ErrorText, - ) - if transactionResponse.StatusId == "F" { - entry.TotalAmount = int64(transactionResponse.Amount * 100) - entry.State = state.StateCompleted - } else { - return VivaDb{}, errors.New("received invalid status = " + transactionResponse.StatusId) - } - } + if response.StatusId == "F" { + paidAmount := int64(response.Amount * 100) + entry.Amount = &paidAmount + entry.State = state.StateCompleted } else { - return VivaDb{}, errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(response.StatusCode), 10)) + return database.PaymentEntry{}, errors.New("received invalid status = " + response.StatusId) } return entry, nil } -func (s *Service) BasicAuth() string { +func (s *Service) CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error) { + httpResponse, err := createRequest( + "DELETE", + "https://demo.vivapayments.com/api/transactions/"+entry.TransactionId.String(), + map[string]string{"authorization": "Bearer " + s.basicAuth(), + "content-type": "application/json", + }, + nil, + ) + if err != nil { + return database.PaymentEntry{}, err + } + + var response TransactionResponse + err = readResponse(httpResponse, &response) + if err != nil { + return database.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 entry, nil +} + +func (s *Service) oAuthToken() (string, error) { + if s.token != "" && s.expiration.After(time.Now()) { + return s.token, nil + } + return s.fetchOAuthToken() +} + +func readResponse[T any](httpResponse *http.Response, response T) error { + if httpResponse.StatusCode == http.StatusOK { + content, err := io.ReadAll(httpResponse.Body) + if err != nil { + return err + } + return json.Unmarshal(content, response) + } else { + return errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(httpResponse.StatusCode), 10)) + } +} + +func createRequest(method string, url string, headers map[string]string, content []byte) (*http.Response, error) { + request, err := http.NewRequest(method, url, bytes.NewReader(content)) + if err != nil { + return nil, err + } + for key, value := range headers { + request.Header.Add(key, value) + } + return http.DefaultClient.Do(request) +} + +func toJson[T any](request T) []byte { + response, err := json.Marshal(request) + if err != nil { + panic(err) + } + return response +} + +func (s *Service) fetchOAuthToken() (string, error) { + form := url.Values{ + "grant_type": []string{"client_credentials"}, + } + httpResponse, err := createRequest( + "POST", + "https://demo-accounts.vivapayments.com/connect/token", + map[string]string{"content-type": "application/x-www-form-urlencoded", "authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(s.ClientId+":"+s.ClientSecret))}, + []byte(form.Encode()), + ) + var response OAuthResponse + + err = readResponse(httpResponse, &response) + if err != nil { + return "", err + } + s.token = response.AccessToken + s.expiration = time.Now().Add(time.Duration(response.ExpiresIn) * time.Second) + + return s.token, nil +} + +func (s *Service) basicAuth() string { return base64.StdEncoding.EncodeToString([]byte(s.MerchantId + ":" + s.ApiKey)) } -func (s *Service) CancelTransaction(entry VivaDb) (VivaDb, error) { - request, err := http.NewRequest("DELETE", "https://demo.vivapayments.com/api/transactions/"+entry.TransactionId.String()+"?amount="+strconv.FormatInt(entry.TotalAmount, 10), bytes.NewReader([]byte{})) - request.Header.Add("authorization", "Bearer "+s.BasicAuth()) +func (s *Service) HandleResponse(c *gin.Context, expectedState state.PaymentState) (string, error) { + transactionId := uuid.MustParse(c.Query("t")) + orderId := database.OrderId(c.Query("s")) + lang := c.Query("lang") + eventId := c.Query("eventId") + eci := c.Query("eci") - response, err := http.DefaultClient.Do(request) + log.Printf("Received error response for viva payment %s", orderId) + entry, err := s.Provider.FetchByOrderId(orderId) if err != nil { - return VivaDb{}, err + log.Printf("Couldn't find payment info for viva payment %s", orderId) + return "", err } - if response.StatusCode == http.StatusOK { - transactionResponse := VivaTransactionResponse{} - content, err := io.ReadAll(response.Body) - if err != nil { - return VivaDb{}, err - } - if err := json.Unmarshal(content, &transactionResponse); err != nil { - return VivaDb{}, err - } else { - log.Printf("Received transaction response: success=%v, eventId=%d, status=%s, amount=%f, errorCode=%d, errorText=%s", - transactionResponse.Success, transactionResponse.EventId, transactionResponse.StatusId, transactionResponse.Amount, transactionResponse.ErrorCode, transactionResponse.ErrorText, - ) - if transactionResponse.StatusId == "F" { - entry.State = state.StateVoided - } else { - return VivaDb{}, errors.New("received invalid status = " + transactionResponse.StatusId) - } - } - } else { - return VivaDb{}, errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(response.StatusCode), 10)) + + entry.State = expectedState + entry.ECI = &eci + entry.Lang = &lang + entry.EventId = &eventId + entry.TransactionId = &transactionId + + if _, err := s.Provider.UpdateEntry(entry); err != nil { + return "", err } - return entry, nil + + log.Printf("Viva payment %s received correctly, returning redirect", orderId) + return "/entries/" + entry.Id.String(), nil } diff --git a/viva/viva.go b/viva/viva.go index 64336a4..18e2f26 100644 --- a/viva/viva.go +++ b/viva/viva.go @@ -1,17 +1,8 @@ package viva -type OrderId string +import "payment-poc/database" -func (o OrderId) MarshalJSON() ([]byte, error) { - return []byte(o), nil -} - -func (o *OrderId) UnmarshalJSON(value []byte) error { - *o = OrderId(value) - return nil -} - -type VivaOrderRequest struct { +type OrderRequest struct { Amount int64 `json:"amount"` Description string `json:"customerTrns"` MerchantDescription string `json:"merchantTrns"` @@ -20,21 +11,21 @@ type VivaOrderRequest struct { Source string `json:"sourceCode"` } -type VivaOrderResponse struct { - OrderId OrderId `json:"orderCode"` +type OrderResponse struct { + OrderId database.OrderId `json:"orderCode"` } -type VivaOAuthResponse struct { +type OAuthResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } -type VivaTransactionCompleteRequest struct { +type TransactionCompleteRequest struct { Amount int64 `json:"amount"` CustomerDescription string `json:"customerTrns"` } -type VivaTransactionResponse struct { +type TransactionResponse struct { Amount float64 `json:"Amount"` StatusId string `json:"StatusId"` ErrorCode int64 `json:"ErrorCode"` diff --git a/wspay/service.go b/wspay/service.go index 8fdcbfb..8582e55 100644 --- a/wspay/service.go +++ b/wspay/service.go @@ -1,60 +1,186 @@ package wspay import ( + "bytes" "crypto/sha512" "encoding/hex" + "encoding/json" "errors" + "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/jmoiron/sqlx" + "io" + "net/http" + "payment-poc/database" "payment-poc/state" "strconv" ) type Service struct { - DB *sqlx.DB + Provider *database.PaymentEntryProvider + ShopId string + ShopSecret string + BackendUrl string } -func (s *Service) CreateEntry(shopId string, totalAmount int64) (WsPayDb, error) { - id := uuid.Must(uuid.NewRandom()) - entry := WsPayDb{ - Id: id, - ShopID: shopId, - ShoppingCartID: id.String(), - TotalAmount: totalAmount, - State: state.StateInitialized, - } - _, err := s.DB.Exec(`INSERT INTO "wspay" ("id", "shop_id", "shopping_card_id", "total_amount", "payment_state") VALUES ($1, $2, $3, $4, $5)`, - &entry.Id, &entry.ShopID, &entry.ShoppingCartID, &entry.TotalAmount, &entry.State, - ) +func (s *Service) CreatePaymentUrl(amount int64) (string, error) { + entry, err := s.Provider.CreateEntry(database.PaymentEntry{ + Gateway: state.GatewayVivaWallet, + State: state.StateInitialized, + TotalAmount: amount, + }) if err != nil { - return WsPayDb{}, err + return "", err } - return s.FetchById(id) + return "/wspay/initialize/" + entry.Id.String(), nil } -func (s *Service) FetchAll() ([]WsPayDb, error) { - var entries []WsPayDb - err := s.DB.Select(&entries, `SELECT * FROM "wspay"`) - return entries, err +func (s *Service) CompleteTransaction(entry database.PaymentEntry, amount int64) (database.PaymentEntry, error) { + if entry.State == state.StateAccepted { + var request = CompletionRequest{ + Version: "2.0", + WsPayOrderId: entry.Id.String(), + 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), + } + + httpResponse, err := createRequest( + "POST", + "https://test.wspay.biz/api/services/completion", + map[string]string{"content-type": "application/json"}, + toJson(request), + ) + if err != nil { + return database.PaymentEntry{}, err + } + + var response CompletionResponse + err = readResponse(httpResponse, &response) + if err != nil { + return database.PaymentEntry{}, err + } + + if CompareCompletionReturnSignature(response.Signature, s.ShopId, s.ShopSecret, entry.Id.String(), *entry.STAN, response.ActionSuccess, response.ApprovalCode) != nil { + entry.Amount = &amount + entry.State = state.StateCompleted + } else { + return database.PaymentEntry{}, errors.New("invalid signature") + } + return entry, nil + } else { + return database.PaymentEntry{}, errors.New("payment is in invalid state") + } } -func (s *Service) FetchById(id uuid.UUID) (WsPayDb, error) { - entry := WsPayDb{} - err := s.DB.Get(&entry, `SELECT * FROM "wspay" WHERE "id" = $1`, id) - return entry, err +func (s *Service) CancelTransaction(entry database.PaymentEntry) (database.PaymentEntry, error) { + if entry.State == state.StateAccepted { + var request = CompletionRequest{ + Version: "2.0", + WsPayOrderId: entry.Id.String(), + 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), + } + + httpResponse, err := createRequest( + "POST", + "https://test.wspay.biz/api/services/void", + map[string]string{"content-type": "application/json"}, + toJson(request), + ) + if err != nil { + return database.PaymentEntry{}, err + } + + var response CompletionResponse + err = readResponse(httpResponse, &response) + if err != nil { + 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 + } else { + return database.PaymentEntry{}, errors.New("invalid signature") + } + return entry, nil + } else { + return database.PaymentEntry{}, errors.New("payment is in invalid state") + } } -func (s *Service) FetchByShoppingCartID(id string) (WsPayDb, error) { - entry := WsPayDb{} - err := s.DB.Get(&entry, `SELECT * FROM "wspay" WHERE "shopping_card_id" = $1`, id) - return entry, err +func (s *Service) InitializePayment(entry database.PaymentEntry) Form { + form := Form{ + ShopID: s.ShopId, + ShoppingCartID: entry.Id.String(), + Version: "2.0", + TotalAmount: entry.TotalAmount, + ReturnURL: s.BackendUrl + "/wspay/success", + ReturnErrorURL: s.BackendUrl + "/wspay/error", + CancelURL: s.BackendUrl + "/wspay/cancel", + Signature: CalculateFormSignature(s.ShopId, s.ShopSecret, entry.Id.String(), entry.TotalAmount), + } + return form } -func (s *Service) Update(entry WsPayDb) error { - _, err := s.DB.Exec(`UPDATE "wspay" set "lang" = $2, "customer_first_name" = $3, "customer_last_name" = $4, "customer_address" = $5, "customer_city" = $6, "customer_zip" = $7, "customer_country" = $8, "customer_phone" = $9, "payment_plan" = $10, "credit_card_name" = $11, "credit_card_number" = $12, "payment_method" = $13, "currency_code" = $14, "date_time" = $15, "eci" = $16, "stan" = $17, "success" = $18, "approval_code" = $19, "error_message" = $20, "error_codes" = $21, "payment_state" = $22 WHERE "id" = $1`, - &entry.Id, &entry.Lang, &entry.CustomerFirstName, &entry.CustomerLastName, &entry.CustomerAddress, &entry.CustomerCity, &entry.CustomerZIP, &entry.CustomerCountry, &entry.CustomerPhone, &entry.PaymentPlan, &entry.CreditCardName, &entry.CreditCardNumber, &entry.PaymentMethod, &entry.CurrencyCode, &entry.DateTime, &entry.ECI, &entry.STAN, &entry.Success, &entry.ApprovalCode, &entry.ErrorMessage, &entry.ErrorCodes, &entry.State, - ) - return err +func (s *Service) HandleSuccessResponse(c *gin.Context) (string, error) { + response := FormReturn{} + if err := c.ShouldBind(&response); err != nil { + return "", err + } + entry, err := s.Provider.FetchById(uuid.MustParse(response.ShoppingCartID)) + if err != nil { + return "", err + } + + if err := CompareFormReturnSignature(response.Signature, s.ShopId, s.ShopSecret, response.ShoppingCartID, response.Success, response.ApprovalCode); err != nil { + return "", err + } + + entry.Lang = &response.Lang + entry.ECI = &response.ECI + entry.STAN = &response.STAN + entry.Success = &response.Success + entry.ApprovalCode = &response.ApprovalCode + entry.State = state.StateAccepted + + if _, err := s.Provider.UpdateEntry(entry); err != nil { + return "", err + } + + return "/entries/" + entry.Id.String(), nil +} + +func (s *Service) HandleErrorResponse(c *gin.Context, paymentState state.PaymentState) (string, error) { + response := FormError{} + if err := c.ShouldBind(&response); err != nil { + return "", err + } + entry, err := s.Provider.FetchById(uuid.MustParse(response.ShoppingCartID)) + if err != nil { + return "", err + } + + if err := CompareFormReturnSignature(response.Signature, s.ShopId, s.ShopSecret, response.ShoppingCartID, response.Success, response.ApprovalCode); err != nil { + return "", err + } + + entry.Lang = &response.Lang + entry.ECI = &response.ECI + entry.Success = &response.Success + entry.ApprovalCode = &response.ApprovalCode + entry.Error = &response.ErrorMessage + entry.State = paymentState + + if _, err := s.Provider.UpdateEntry(entry); err != nil { + return "", err + } + + return "/entries/" + entry.Id.String(), nil } func CalculateFormSignature(shopId string, secret string, cartId string, amount int64) string { @@ -141,3 +267,34 @@ func CompareCompletionReturnSignature(signature string, shopId string, secret st return errors.New("signature mismatch") } } + +func readResponse[T any](httpResponse *http.Response, response T) error { + if httpResponse.StatusCode == http.StatusOK { + content, err := io.ReadAll(httpResponse.Body) + if err != nil { + return err + } + return json.Unmarshal(content, response) + } else { + return errors.New("received wrong status, expected 200 received " + strconv.FormatInt(int64(httpResponse.StatusCode), 10)) + } +} + +func createRequest(method string, url string, headers map[string]string, content []byte) (*http.Response, error) { + request, err := http.NewRequest(method, url, bytes.NewReader(content)) + if err != nil { + return nil, err + } + for key, value := range headers { + request.Header.Add(key, value) + } + return http.DefaultClient.Do(request) +} + +func toJson[T any](request T) []byte { + response, err := json.Marshal(request) + if err != nil { + panic(err) + } + return response +} diff --git a/wspay/wspay.go b/wspay/wspay.go index 6596c69..56d3963 100644 --- a/wspay/wspay.go +++ b/wspay/wspay.go @@ -2,7 +2,7 @@ package wspay const AuthorisationForm = "https://formtest.wspay.biz/authorization.aspx" -type WsPayForm struct { +type Form struct { // required args ShopID string ShoppingCartID string @@ -31,7 +31,7 @@ type WsPayForm struct { CurrencyCode int } -type WsPayFormReturn struct { +type FormReturn struct { CustomerFirstName string `form:"CustomerFirstname"` CustomerSurname string `form:"CustomerSurname"` CustomerAddress string `form:"CustomerAddress"` @@ -61,7 +61,7 @@ type WsPayFormReturn struct { Signature string `form:"Signature"` } -type WsPayFormError struct { +type FormError struct { CustomerFirstName string CustomerSurname string CustomerAddress string @@ -88,7 +88,7 @@ type WsPayFormError struct { Signature string } -type WsPayFormCancel struct { +type FormCancel struct { ResponseCode int ShoppingCartID string ApprovalCode string @@ -96,7 +96,7 @@ type WsPayFormCancel struct { Signature string } -type WsPayCompletionRequest struct { +type CompletionRequest struct { Version string WsPayOrderId string ShopId string @@ -106,7 +106,7 @@ type WsPayCompletionRequest struct { Signature string } -type WsPayCompletionResponse struct { +type CompletionResponse struct { WsPayOrderId string ShopId string ApprovalCode string @@ -116,14 +116,14 @@ type WsPayCompletionResponse struct { Signature string } -type WsPayStatusCheckRequest struct { +type StatusCheckRequest struct { Version string ShopId string ShoppingCartId string Signature string } -type WsPayStatusCheckResponse struct { +type StatusCheckResponse struct { WsPayOrderId string Signature string STAN string