package main import ( "bytes" "context" "encoding/json" "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" "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" ) var ( grainMutations = promauto.NewCounter(prometheus.CounterOpts{ Name: "checkout_grain_mutations_total", Help: "The total number of mutations", }) grainLookups = promauto.NewCounter(prometheus.CounterOpts{ Name: "checkout_grain_lookups_total", Help: "The total number of lookups", }) ) type CheckoutPoolServer struct { actor.GrainPool[*checkout.CheckoutGrain] pod_name string klarnaClient *KlarnaClient adyenClient *adyen.APIClient cartClient *CartClient } func NewCheckoutPoolServer(pool actor.GrainPool[*checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer { srv := &CheckoutPoolServer{ GrainPool: pool, pod_name: pod_name, klarnaClient: klarnaClient, cartClient: cartClient, adyenClient: adyenClient, } return srv } func (s *CheckoutPoolServer) ApplyLocal(ctx context.Context, id checkout.CheckoutId, mutation ...proto.Message) (*actor.MutationResult[*checkout.CheckoutGrain], error) { return s.Apply(ctx, uint64(id), mutation...) } func (s *CheckoutPoolServer) GetCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error { grain, err := s.Get(r.Context(), uint64(id)) if err != nil { return err } return s.WriteResult(w, grain) } func (s *CheckoutPoolServer) WriteResult(w http.ResponseWriter, result any) error { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("X-Pod-Name", s.pod_name) if result == nil { w.WriteHeader(http.StatusInternalServerError) return nil } w.WriteHeader(http.StatusOK) enc := json.NewEncoder(w) err := enc.Encode(result) return err } func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, id checkout.CheckoutId) (*CheckoutOrder, error) { // Get cart state from cart service cartGrain, err := s.Get(r.Context(), uint64(id)) if err != nil { return nil, err } meta := GetCheckoutMetaFromRequest(r) payload, _, err := BuildCheckoutOrderPayload(cartGrain, meta) if err != nil { return nil, err } grain, err := s.Get(r.Context(), uint64(id)) if err != nil { return nil, err } if grain.OrderId != nil { return s.klarnaClient.UpdateOrder(r.Context(), *grain.OrderId, bytes.NewReader(payload)) } else { return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload)) } } func (s *CheckoutPoolServer) ApplyKlarnaPaymentStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id checkout.CheckoutId) (*actor.MutationResult[*checkout.CheckoutGrain], error) { method := "checkout" return s.ApplyLocal(ctx, id, &messages.PaymentStarted{ PaymentId: klarnaOrder.ID, Amount: int64(klarnaOrder.OrderAmount), Currency: klarnaOrder.PurchaseCurrency, Provider: "klarna", Method: &method, StartedAt: timestamppb.New(time.Now()), }) } func (s *CheckoutPoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) { return CheckoutIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error { orderId := r.URL.Query().Get("order_id") if orderId == "" { order, err := s.CreateOrUpdateCheckout(r, checkoutId) if err != nil { logger.Error("unable to create klarna session", "error", err) return err } s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId) return fn(order, w) } order, err := s.klarnaClient.GetOrder(r.Context(), orderId) if err != nil { return err } return fn(order, w) })) } func (s *CheckoutPoolServer) getCartGrain(ctx context.Context, cartId cart.CartId, version int) (*cart.CartGrain, error) { // Call cart service to get grain url := fmt.Sprintf("%s/internal/cart/%s/%d", s.cartClient.baseUrl, cartId.String(), version) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } resp, err := s.cartClient.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to get cart: %s", resp.Status) } var grain cart.CartGrain err = json.NewDecoder(resp.Body).Decode(&grain) return &grain, err } func CheckoutIdHandler(fn func(checkoutId checkout.CheckoutId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var id checkout.CheckoutId raw := r.PathValue("id") if raw == "" { id = checkout.CheckoutId(cart.MustNewCartId()) w.Header().Set("Set-Checkout-Id", id.String()) } else { if parsedId, ok := cart.ParseCartId(raw); !ok { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("checkout id is invalid")) return } else { id = checkout.CheckoutId(parsedId) } } err := fn(id, w, r) if err != nil { log.Printf("Server error, not remote error: %v\n", err) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) } } } func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(checkoutId checkout.CheckoutId, w http.ResponseWriter, r *http.Request) error { return func(checkoutId checkout.CheckoutId, w http.ResponseWriter, r *http.Request) error { if ownerHost, ok := s.OwnerHost(uint64(checkoutId)); ok { ctx, span := tracer.Start(r.Context(), "proxy") defer span.End() span.SetAttributes(attribute.String("checkoutid", checkoutId.String())) hostAttr := attribute.String("other host", ownerHost.Name()) span.SetAttributes(hostAttr) logger.InfoContext(ctx, "checkout proxyed", "result", ownerHost.Name()) proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr)) handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil) grainLookups.Inc() if err == nil && handled { return nil } } _, span := tracer.Start(r.Context(), "own") span.SetAttributes(attribute.String("checkoutid", checkoutId.String())) defer span.End() return fn(w, r, checkoutId) } } var ( tracer = otel.Tracer(name) hmacKey = os.Getenv("ADYEN_HMAC") meter = otel.Meter(name) logger = otelslog.NewLogger(name) proxyCalls metric.Int64Counter ) func init() { var err error proxyCalls, err = meter.Int64Counter("proxy.calls", metric.WithDescription("Number of proxy calls"), metric.WithUnit("{calls}")) if err != nil { panic(err) } } 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) { 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) { handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) { attr := attribute.String("http.route", pattern) mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { span := trace.SpanFromContext(r.Context()) span.SetName(pattern) span.SetAttributes(attr) labeler, _ := otelhttp.LabelerFromContext(r.Context()) labeler.Add(attr) handlerFunc(w, r) })) } 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") w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")") w.WriteHeader(http.StatusOK) _, err := fmt.Fprintf(w, tpl, order.HTMLSnippet) return err })) handleFunc("GET /confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) { orderId := r.PathValue("order_id") order, err := s.klarnaClient.GetOrder(r.Context(), orderId) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return } // Apply ConfirmationViewed to checkout checkoutId := checkout.CheckoutId(cart.MustNewCartId()) // Need to resolve from order s.Apply(r.Context(), uint64(checkoutId), &messages.ConfirmationViewed{}) // Callback to cart cartId := cart.CartId(checkoutId) // Assuming same s.cartClient.ApplyMutation(cartId, &messages.OrderCreated{OrderId: order.ID, Status: order.Status}) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, tpl, order.HTMLSnippet) }) }