klarna and checkouts
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-26 15:41:55 +02:00
parent c0c5a8bc0f
commit 0252893de5
3 changed files with 71 additions and 24 deletions
+47 -17
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/mats/go-cart-actor/pkg/checkout"
@@ -11,18 +13,41 @@ import (
"github.com/adyen/adyen-go-api-library/v21/src/common" "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 // CheckoutMeta carries the external / URL metadata required to build a
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes // Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
// any Klarna-specific response fields (HTML snippet, client token, etc.). // any Klarna-specific response fields (HTML snippet, client token, etc.).
type CheckoutMeta struct { type CheckoutMeta struct {
SiteUrl string SiteUrl string
// Terms string // CallbackBaseUrl is the base for Klarna server-to-server callbacks
// Checkout string // (push/notification/validate); may differ from SiteUrl in prod where the
// Confirmation string // checkout service and storefront are on different hosts.
ClientIp string CallbackBaseUrl string
Country string ClientIp string
Currency string // optional override (defaults to "SEK" if empty) Country string
Locale string // optional override (defaults to "sv-se" if empty) 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 // BuildCheckoutOrderPayload converts the current cart grain + meta information
@@ -113,11 +138,11 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
MerchantReference1: grain.Id.String(), MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{ MerchantURLS: &CheckoutMerchantURLS{
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl), Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl), Checkout: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Confirmation: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl), Confirmation: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Notification: "https://cart.k6n.net/payment/klarna/notification", Notification: fmt.Sprintf("%s/payment/klarna/notification", meta.CallbackBaseUrl),
Validation: "https://cart.k6n.net/payment/klarna/validate", Validation: fmt.Sprintf("%s/payment/klarna/validate", meta.CallbackBaseUrl),
Push: "https://cart.k6n.net/payment/klarna/push?order_id={checkout.order.id}", 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 { func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
host := getOriginalHost(r) host := getOriginalHost(r)
country := getCountryFromHost(host) country := getCountryFromHost(host)
siteUrl := fmt.Sprintf("%s://%s", getScheme(r), host)
if checkoutPublicURL != "" {
siteUrl = checkoutPublicURL
}
return &CheckoutMeta{ return &CheckoutMeta{
ClientIp: getClientIp(r), ClientIp: getClientIp(r),
SiteUrl: fmt.Sprintf("https://%s", host), SiteUrl: siteUrl,
Country: country, CallbackBaseUrl: checkoutCallbackBaseURL,
Currency: getCurrency(country), Country: country,
Locale: getLocale(country), Currency: getCurrency(country),
Locale: getLocale(country),
} }
} }
+11 -7
View File
@@ -183,16 +183,20 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
if s.inventoryService != nil { if s.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items) inventoryRequests := getInventoryRequests(grain.CartState.Items)
err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...) invStatus := "success"
if err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...); err != nil {
if err != nil { // The payment is already settled at Klarna (checkout_complete), so the
logger.WarnContext(r.Context(), "placeorder inventory reservation failed") // order MUST be created. Inventory is best-effort at this point — a
w.WriteHeader(http.StatusNotAcceptable) // reservation failure (unstocked / drop-ship / non-tracked items)
return // 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{ s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(), Id: grain.Id.String(),
Status: "success", Status: invStatus,
}) })
} }
+13
View File
@@ -21,6 +21,19 @@ func getOriginalHost(r *http.Request) string {
return r.Host 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 { func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For") ip := r.Header.Get("X-Forwarded-For")
if ip == "" { if ip == "" {