diff --git a/cmd/checkout/checkout_builder.go b/cmd/checkout/checkout_builder.go index 2f0fc8e..9a10ce7 100644 --- a/cmd/checkout/checkout_builder.go +++ b/cmd/checkout/checkout_builder.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "strings" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/checkout" @@ -11,18 +13,41 @@ import ( "github.com/adyen/adyen-go-api-library/v21/src/common" ) +// defaultCallbackBaseURL is where Klarna's server-to-server callbacks +// (push/notification/validate) are sent when CHECKOUT_CALLBACK_BASE_URL is unset. +// These must be a publicly reachable https origin for the checkout service. +const defaultCallbackBaseURL = "https://cart.k6n.net" + +var ( + // checkoutPublicURL overrides the request-derived site base for the + // shopper-facing Klarna URLs (terms/checkout/confirmation). Set this to a + // public https origin (e.g. a tunnel or shop-test.tornberg.me) for local + // KCO testing, since Klarna rejects non-https merchant_urls. Empty = derive + // from the request (X-Forwarded-Host + https). + checkoutPublicURL = strings.TrimRight(os.Getenv("CHECKOUT_PUBLIC_URL"), "/") + // checkoutCallbackBaseURL is the base for Klarna's server-to-server + // callbacks. Defaults to defaultCallbackBaseURL. + checkoutCallbackBaseURL = func() string { + if v := strings.TrimRight(os.Getenv("CHECKOUT_CALLBACK_BASE_URL"), "/"); v != "" { + return v + } + return defaultCallbackBaseURL + }() +) + // CheckoutMeta carries the external / URL metadata required to build a // Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes // any Klarna-specific response fields (HTML snippet, client token, etc.). type CheckoutMeta struct { SiteUrl string - // Terms string - // Checkout string - // Confirmation string - ClientIp string - Country string - Currency string // optional override (defaults to "SEK" if empty) - Locale string // optional override (defaults to "sv-se" if empty) + // CallbackBaseUrl is the base for Klarna server-to-server callbacks + // (push/notification/validate); may differ from SiteUrl in prod where the + // checkout service and storefront are on different hosts. + CallbackBaseUrl string + ClientIp string + Country string + Currency string // optional override (defaults to "SEK" if empty) + Locale string // optional override (defaults to "sv-se" if empty) } // BuildCheckoutOrderPayload converts the current cart grain + meta information @@ -113,11 +138,11 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta MerchantReference1: grain.Id.String(), MerchantURLS: &CheckoutMerchantURLS{ Terms: fmt.Sprintf("%s/terms", meta.SiteUrl), - Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl), - Confirmation: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl), - Notification: "https://cart.k6n.net/payment/klarna/notification", - Validation: "https://cart.k6n.net/payment/klarna/validate", - Push: "https://cart.k6n.net/payment/klarna/push?order_id={checkout.order.id}", + Checkout: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl), + Confirmation: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl), + Notification: fmt.Sprintf("%s/payment/klarna/notification", meta.CallbackBaseUrl), + Validation: fmt.Sprintf("%s/payment/klarna/validate", meta.CallbackBaseUrl), + Push: fmt.Sprintf("%s/payment/klarna/push?order_id={checkout.order.id}", meta.CallbackBaseUrl), }, } @@ -132,12 +157,17 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta { host := getOriginalHost(r) country := getCountryFromHost(host) + siteUrl := fmt.Sprintf("%s://%s", getScheme(r), host) + if checkoutPublicURL != "" { + siteUrl = checkoutPublicURL + } return &CheckoutMeta{ - ClientIp: getClientIp(r), - SiteUrl: fmt.Sprintf("https://%s", host), - Country: country, - Currency: getCurrency(country), - Locale: getLocale(country), + ClientIp: getClientIp(r), + SiteUrl: siteUrl, + CallbackBaseUrl: checkoutCallbackBaseURL, + Country: country, + Currency: getCurrency(country), + Locale: getLocale(country), } } diff --git a/cmd/checkout/klarna-handlers.go b/cmd/checkout/klarna-handlers.go index f6d11f3..ac7a174 100644 --- a/cmd/checkout/klarna-handlers.go +++ b/cmd/checkout/klarna-handlers.go @@ -183,16 +183,20 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re if s.inventoryService != nil { inventoryRequests := getInventoryRequests(grain.CartState.Items) - err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...) - - if err != nil { - logger.WarnContext(r.Context(), "placeorder inventory reservation failed") - w.WriteHeader(http.StatusNotAcceptable) - return + invStatus := "success" + if err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...); err != nil { + // The payment is already settled at Klarna (checkout_complete), so the + // order MUST be created. Inventory is best-effort at this point — a + // reservation failure (unstocked / drop-ship / non-tracked items) + // must not block order creation. Log it and flag the grain so + // fulfillment can reconcile. + logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway", + "err", err, "checkoutId", grain.Id.String()) + invStatus = "failed" } s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{ Id: grain.Id.String(), - Status: "success", + Status: invStatus, }) } diff --git a/cmd/checkout/utils.go b/cmd/checkout/utils.go index 899dee1..d016b6b 100644 --- a/cmd/checkout/utils.go +++ b/cmd/checkout/utils.go @@ -21,6 +21,19 @@ func getOriginalHost(r *http.Request) string { return r.Host } +// getScheme resolves the public scheme for building absolute URLs (e.g. Klarna +// merchant_urls). Honors X-Forwarded-Proto set by the edge/dev proxy; defaults +// to https so production (TLS terminated at nginx/ingress, no header) is correct. +func getScheme(r *http.Request) string { + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + return proto + } + if r.TLS != nil { + return "https" + } + return "https" +} + func getClientIp(r *http.Request) string { ip := r.Header.Get("X-Forwarded-For") if ip == "" {