package main import ( "context" "encoding/json" "errors" "fmt" "log" "net/http" "net/url" "time" "git.k6n.net/mats/go-cart-actor/pkg/cart" messages "git.k6n.net/mats/go-cart-actor/proto/checkout" 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" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/timestamppb" ) type SessionRequest struct { SessionId *string `json:"sessionId,omitempty"` 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 { // service := s.adyenClient.Checkout() // req := service.PaymentsApi.GetResultOfPaymentSessionInput(pa).SessionResult(payload.SessionResult) // res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req) // 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 getCheckoutIdFromNotificationItem(item webhook.NotificationRequestItem) (*cart.CartId, error) { cartId, ok := cart.ParseCartId(item.MerchantReference) if !ok { log.Printf("The notification does not have a valid cartId: %s", item.MerchantReference) return nil, errors.New("invalid cart id") } return &cartId, nil } 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 } 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 { // Marshal item data for PaymentEvent dataBytes, err := json.Marshal(item) if err != nil { log.Printf("error marshaling item: %v", err) http.Error(w, "Error marshaling item", http.StatusInternalServerError) return } switch item.EventCode { case "CAPTURE": checkoutId, err := getCheckoutIdFromNotificationItem(item) if err != nil { log.Printf("Could not get checkout id: %v", err) http.Error(w, err.Error(), http.StatusBadRequest) return } log.Printf("Capture status: %v", item.Success) isSuccess := item.Success == "true" // If successful, apply payment completed //if isSuccess { if err := s.ApplyAnywhere(r.Context(), *checkoutId, &messages.PaymentEvent{ PaymentId: item.PspReference, Success: isSuccess, Name: item.EventCode, Data: &anypb.Any{Value: dataBytes}, }, &messages.PaymentCompleted{ PaymentId: item.PspReference, Status: item.Success, Amount: item.Amount.Value, Currency: item.Amount.Currency, ProcessorReference: &item.PspReference, CompletedAt: timestamppb.New(time.Now()), }); err != nil { http.Error(w, "Message not parsed", http.StatusInternalServerError) return } //} // CAPTURE is the moment the Adyen payment is fully settled — the // Adyen analogue of the Klarna push. Create the event-sourced // order now (mirrors KlarnaPushHandler). Idempotent on // "checkout-{checkoutId}", so a retried CAPTURE won't duplicate. if isSuccess && s.orderClient != nil { s.createAdyenOrder(r.Context(), *checkoutId, item) } case "AUTHORISATION": isSuccess := item.Success == "true" log.Printf("Handling auth: %+v", item) checkoutId, err := getCheckoutIdFromNotificationItem(item) if err != nil { log.Printf("Could not get checkout id: %v", err) http.Error(w, err.Error(), http.StatusBadRequest) return } msgs := []proto.Message{ &messages.PaymentEvent{ PaymentId: item.PspReference, Success: isSuccess, Name: item.EventCode, Data: &anypb.Any{Value: dataBytes}, }, } if isSuccess { msgs = append(msgs, &messages.PaymentCompleted{ PaymentId: item.PspReference, Status: item.Success, Amount: item.Amount.Value, CompletedAt: timestamppb.Now(), }) } else { msgs = append(msgs, &messages.PaymentDeclined{ PaymentId: item.PspReference, Message: item.Reason, }) } if err := s.ApplyAnywhere(r.Context(), *checkoutId, msgs...); err != nil { log.Printf("error applying authorization event: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } // If successful authorization, trigger capture if isSuccess { pspReference := item.PspReference uid := uuid.New().String() ref := checkoutId.String() req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(adyenCheckout.PaymentCaptureRequest{ Amount: adyenCheckout.Amount(item.Amount), MerchantAccount: "ElgigantenECOM", Reference: &ref, }) res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req) if err != nil { log.Printf("Error capturing payment: %v", err) } else { // Capture requested. The order is NOT created here — we // only have a PSP reference, not an order. Adyen sends a // CAPTURE notification once the capture settles, and the // CAPTURE branch above creates the event-sourced order. log.Printf("Payment capture requested successfully: %+v", res) } } default: log.Printf("Unknown event code: %s", item.EventCode) log.Printf("Item data: %+v", item) isSuccess := item.Success == "true" checkoutId, err := getCheckoutIdFromNotificationItem(item) if err != nil { log.Printf("Could not get checkout id: %v", err) } else { if err := s.ApplyAnywhere(r.Context(), *checkoutId, &messages.PaymentEvent{ PaymentId: item.PspReference, Success: isSuccess, Name: item.EventCode, Data: &anypb.Any{Value: dataBytes}, }); err != nil { log.Printf("error applying payment event: %v", err) } } } } } w.WriteHeader(http.StatusAccepted) } // createAdyenOrder turns a settled (captured) Adyen payment into an // event-sourced order, mirroring KlarnaPushHandler. The Adyen webhook carries // the charged amount + currency but not the shopper's country/locale, so those // are derived from the currency. Failures are logged and left for the next // CAPTURE notification to retry — the from-checkout endpoint is idempotent on // "checkout-{checkoutId}". func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId cart.CartId, item webhook.NotificationRequestItem) { grain, err := s.GetAnywhere(ctx, checkoutId) if err != nil { log.Printf("from-checkout: could not load checkout grain %s: %v", checkoutId.String(), err) return } // Reserve inventory once, guarded by the grain flag so a retried CAPTURE // does not decrement stock twice. (Same as the Klarna path.) if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved { if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil { log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr) } else { s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{ Id: grain.Id.String(), Status: "success", }) } } country := getCountryFromCurrency(item.Amount.Currency) if _, oerr := createOrderFromSettledCheckout(ctx, s, grain, settledPayment{ Provider: "adyen", Reference: item.PspReference, Amount: item.Amount.Value, Currency: item.Amount.Currency, Country: country, Locale: getLocale(country), }); oerr != nil { log.Printf("from-checkout failed for checkout %s; will retry on next CAPTURE: %v", checkoutId.String(), oerr) } } 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) }