diff --git a/cmd/checkout/cart-client.go b/cmd/checkout/cart-client.go new file mode 100644 index 0000000..9dbcecc --- /dev/null +++ b/cmd/checkout/cart-client.go @@ -0,0 +1,45 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "time" + + "git.k6n.net/go-cart-actor/pkg/cart" + "github.com/gogo/protobuf/proto" +) + +type CartClient struct { + httpClient *http.Client + baseUrl string +} + +func NewCartClient(baseUrl string) *CartClient { + return &CartClient{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + baseUrl: baseUrl, + } +} + +func (c *CartClient) ApplyMutation(cartId cart.CartId, mutation proto.Message) error { + url := fmt.Sprintf("%s/internal/cart/%s/mutation", c.baseUrl, cartId.String()) + data, err := proto.Marshal(mutation) + if err != nil { + return err + } + req, err := http.NewRequest("POST", url, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/protobuf") + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("cart mutation failed: %s", resp.Status) + } + return nil +} diff --git a/cmd/checkout/main.go b/cmd/checkout/main.go index c5f8fc8..08fdf50 100644 --- a/cmd/checkout/main.go +++ b/cmd/checkout/main.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "context" "fmt" "log" @@ -9,16 +8,13 @@ import ( "net/http" "os" "os/signal" - "strings" "time" "git.k6n.net/go-cart-actor/pkg/actor" - "git.k6n.net/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/checkout" "git.k6n.net/go-cart-actor/pkg/proxy" "github.com/adyen/adyen-go-api-library/v21/src/adyen" "github.com/adyen/adyen-go-api-library/v21/src/common" - "github.com/gogo/protobuf/proto" "github.com/matst80/go-redis-inventory/pkg/inventory" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -51,50 +47,6 @@ var redisAddress = os.Getenv("REDIS_ADDRESS") var redisPassword = os.Getenv("REDIS_PASSWORD") var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081 -func getCountryFromHost(host string) string { - if strings.Contains(strings.ToLower(host), "-no") { - return "no" - } - if strings.Contains(strings.ToLower(host), "-se") { - return "se" - } - return "" -} - -type CartClient struct { - httpClient *http.Client - baseUrl string -} - -func NewCartClient(baseUrl string) *CartClient { - return &CartClient{ - httpClient: &http.Client{Timeout: 10 * time.Second}, - baseUrl: baseUrl, - } -} - -func (c *CartClient) ApplyMutation(cartId cart.CartId, mutation proto.Message) error { - url := fmt.Sprintf("%s/internal/cart/%s/mutation", c.baseUrl, cartId.String()) - data, err := proto.Marshal(mutation) - if err != nil { - return err - } - req, err := http.NewRequest("POST", url, bytes.NewReader(data)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/protobuf") - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("cart mutation failed: %s", resp.Status) - } - return nil -} - func main() { controlPlaneConfig := actor.DefaultServerConfig() diff --git a/cmd/checkout/pool-server.go b/cmd/checkout/pool-server.go index bd10f0b..1be4a38 100644 --- a/cmd/checkout/pool-server.go +++ b/cmd/checkout/pool-server.go @@ -7,15 +7,22 @@ import ( "fmt" "log" "net/http" + "net/url" "os" "time" "git.k6n.net/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/checkout" + "git.k6n.net/go-cart-actor/pkg/proxy" messages "git.k6n.net/go-cart-actor/proto/checkout" adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen" + adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout" + "github.com/adyen/adyen-go-api-library/v21/src/common" + "github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator" + "github.com/adyen/adyen-go-api-library/v21/src/webhook" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -30,36 +37,6 @@ import ( "go.opentelemetry.io/otel/trace" ) -func getOriginalHost(r *http.Request) string { - proxyHost := r.Header.Get("X-Forwarded-Host") - if proxyHost != "" { - return proxyHost - } - return r.Host -} - -func getClientIp(r *http.Request) string { - ip := r.Header.Get("X-Forwarded-For") - if ip == "" { - ip = r.RemoteAddr - } - return ip -} - -func getCurrency(country string) string { - if country == "no" { - return "NOK" - } - return "SEK" -} - -func getLocale(country string) string { - if country == "no" { - return "nb-no" - } - return "sv-se" -} - var ( grainMutations = promauto.NewCounter(prometheus.CounterOpts{ Name: "checkout_grain_mutations_total", @@ -267,8 +244,204 @@ func init() { } } +type SessionRequest struct { + SessionId string `json:"sessionId"` + SessionResult string `json:"sessionResult"` + SessionData string `json:"sessionData,omitempty"` +} + +func (s *CheckoutPoolServer) AdyenSessionHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { + + grain, err := s.Get(r.Context(), uint64(cartId)) + if err != nil { + return err + } + if r.Method == http.MethodGet { + meta := GetCheckoutMetaFromRequest(r) + sessionData, err := BuildAdyenCheckoutSession(grain, meta) + if err != nil { + return err + } + service := s.adyenClient.Checkout() + req := service.PaymentsApi.SessionsInput().CreateCheckoutSessionRequest(*sessionData) + res, _, err := service.PaymentsApi.Sessions(r.Context(), req) + // apply checkout started + if err != nil { + return err + } + return s.WriteResult(w, res) + } else { + payload := &SessionRequest{} + if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + return err + } + service := s.adyenClient.Checkout() + req := service.PaymentsApi.GetResultOfPaymentSessionInput(payload.SessionId).SessionResult(payload.SessionResult) + res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req) + if err != nil { + return err + } + return s.WriteResult(w, res) + } + +} + func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) { - // Similar to cart's, but apply to checkout and then callback to cart + var notificationRequest webhook.Webhook + service := s.adyenClient.Checkout() + if err := json.NewDecoder(r.Body).Decode(¬ificationRequest); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + cartHostMap := make(map[actor.Host][]webhook.NotificationItem) + for _, notificationItem := range *notificationRequest.NotificationItems { + item := notificationItem.NotificationRequestItem + log.Printf("Recieved notification event code: %s, %+v", item.EventCode, item) + + isValid := hmacvalidator.ValidateHmac(item, hmacKey) + if !isValid { + log.Printf("notification hmac not valid %s, %v", item.EventCode, item) + http.Error(w, "Invalid HMAC", http.StatusUnauthorized) + return + } else { + switch item.EventCode { + case "CAPTURE": + log.Printf("Capture status: %v", item.Success) + // dataBytes, err := json.Marshal(item) + // if err != nil { + // log.Printf("error marshaling item: %v", err) + // http.Error(w, "Error marshaling item", http.StatusInternalServerError) + // return + // } + //s.ApplyAnywhere(r.Context(),0, &messages.PaymentEvent{PaymentId: item.PspReference, Success: item.Success, Name: item.EventCode, Data: &pbany.Any{Value: dataBytes}}) + case "AUTHORISATION": + + cartId, ok := cart.ParseCartId(item.MerchantReference) + if !ok { + log.Printf("invalid cart id %s", item.MerchantReference) + http.Error(w, "Invalid cart id", http.StatusBadRequest) + return + } + //s.Apply() + + if host, ok := s.OwnerHost(uint64(cartId)); ok { + cartHostMap[host] = append(cartHostMap[host], notificationItem) + continue + } + + grain, err := s.Get(r.Context(), uint64(cartId)) + if err != nil { + log.Printf("Error getting cart: %v", err) + http.Error(w, "Cart not found", http.StatusBadRequest) + return + } + meta := GetCheckoutMetaFromRequest(r) + pspReference := item.PspReference + uid := uuid.New().String() + ref := uuid.New().String() + req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(adyenCheckout.PaymentCaptureRequest{ + Amount: adyenCheckout.Amount{ + Currency: meta.Currency, + Value: grain.CartTotalPrice.IncVat, + }, + MerchantAccount: "ElgigantenECOM", + Reference: &ref, + }) + res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req) + if err != nil { + log.Printf("Error capturing payment: %v", err) + } else { + log.Printf("Payment captured successfully: %+v", res) + s.Apply(r.Context(), uint64(cartId), &messages.OrderCreated{ + OrderId: res.PaymentPspReference, + Status: item.EventCode, + }) + } + default: + log.Printf("Unknown event code: %s", item.EventCode) + } + } + } + var failed bool = false + var lastMock *proxy.MockResponseWriter + for host, items := range cartHostMap { + notificationRequest.NotificationItems = &items + bodyBytes, err := json.Marshal(notificationRequest) + if err != nil { + log.Printf("error marshaling notification: %+v", err) + continue + } + customBody := bytes.NewReader(bodyBytes) + mockW := proxy.NewMockResponseWriter() + handled, err := host.Proxy(0, mockW, r, customBody) + if err != nil { + log.Printf("proxy failed for %s: %+v", host.Name(), err) + failed = true + lastMock = mockW + } else if handled { + log.Printf("notification proxied to %s", host.Name()) + } + } + if failed { + w.WriteHeader(lastMock.StatusCode) + w.Write(lastMock.Body.Bytes()) + } else { + w.WriteHeader(http.StatusAccepted) + } +} + +func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) { + log.Println("Redirect received") + + service := s.adyenClient.Checkout() + + req := service.PaymentsApi.GetResultOfPaymentSessionInput(r.URL.Query().Get("sessionId")) + + res, httpRes, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req) + log.Printf("got payment session %+v", res) + + dreq := service.PaymentsApi.PaymentsDetailsInput() + dreq = dreq.PaymentDetailsRequest(adyenCheckout.PaymentDetailsRequest{ + Details: adyenCheckout.PaymentCompletionDetails{ + RedirectResult: common.PtrString(r.URL.Query().Get("redirectResult")), + Payload: common.PtrString(r.URL.Query().Get("payload")), + }, + }) + + dres, httpRes, err := service.PaymentsApi.PaymentsDetails(r.Context(), dreq) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("Payment details response: %+v", dres) + + if !common.IsNil(dres.PspReference) && *dres.PspReference != "" { + var redirectURL string + // Conditionally handle different result codes for the shopper + switch *dres.ResultCode { + case "Authorised": + redirectURL = "/result/success" + case "Pending", "Received": + redirectURL = "/result/pending" + case "Refused": + redirectURL = "/result/failed" + default: + reason := "" + if dres.RefusalReason != nil { + reason = *dres.RefusalReason + } else { + reason = *dres.ResultCode + } + log.Printf("Payment failed: %s", reason) + redirectURL = fmt.Sprintf("/result/error?reason=%s", url.QueryEscape(reason)) + } + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpRes.StatusCode) + json.NewEncoder(w).Encode(httpRes.Status) } func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) { @@ -287,6 +460,7 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) { } handleFunc("/adyen_hook", s.AdyenHookHandler) + handleFunc("/adyen-return", s.AdyenReturnHandler) handleFunc("GET /checkout", s.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error { w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/cmd/checkout/utils.go b/cmd/checkout/utils.go new file mode 100644 index 0000000..7e95b1b --- /dev/null +++ b/cmd/checkout/utils.go @@ -0,0 +1,46 @@ +package main + +import ( + "net/http" + "strings" +) + +func getOriginalHost(r *http.Request) string { + proxyHost := r.Header.Get("X-Forwarded-Host") + if proxyHost != "" { + return proxyHost + } + return r.Host +} + +func getClientIp(r *http.Request) string { + ip := r.Header.Get("X-Forwarded-For") + if ip == "" { + ip = r.RemoteAddr + } + return ip +} + +func getCurrency(country string) string { + if country == "no" { + return "NOK" + } + return "SEK" +} + +func getLocale(country string) string { + if country == "no" { + return "nb-no" + } + return "sv-se" +} + +func getCountryFromHost(host string) string { + if strings.Contains(strings.ToLower(host), "-no") { + return "no" + } + if strings.Contains(strings.ToLower(host), "-se") { + return "se" + } + return "" +}