package main 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" "git.k6n.net/mats/platform/tax" adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout" "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 // 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 // into a CheckoutOrder domain struct and returns its JSON-serialized payload // (to send to Klarna) alongside the structured CheckoutOrder object. // // This function is PURE: it does not perform any network I/O or mutate the // grain. The caller is responsible for: // // 1. Choosing whether to create or update the Klarna order. // 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload. // 3. Applying an InitializeCheckout mutation (or equivalent) with the // resulting Klarna order id + status. // // If you later need to support different tax rates per line, you can extend // CartItem / Delivery to expose that data and propagate it here. // tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for // delivery lines instead of the hardcoded 2500 (25 % as CartItem format). func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) ([]byte, *CheckoutOrder, error) { if grain == nil { return nil, nil, fmt.Errorf("nil grain") } if meta == nil { return nil, nil, fmt.Errorf("nil checkout meta") } currency := meta.Currency if currency == "" { currency = "SEK" } locale := meta.Locale if locale == "" { locale = "sv-se" } country := meta.Country if country == "" { country = "SE" // sensible default; adjust if multi-country support changes } lines := make([]*Line, 0, len(grain.CartState.Items)+len(grain.Deliveries)) // Item lines for _, it := range grain.CartState.Items { if it == nil { continue } lines = append(lines, &Line{ Type: "physical", Reference: it.Sku, Name: it.Meta.Name, Quantity: int(it.Quantity), UnitPrice: int(it.Price.IncVat), TaxRate: it.Tax, // TODO: derive if variable tax rates are introduced QuantityUnit: "st", TotalAmount: int(it.TotalPrice.IncVat), TotalTaxAmount: int(it.TotalPrice.TotalVat()), ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image), }) } hasFreeShipping := false if grain.CartState != nil { for _, ap := range grain.CartState.AppliedPromotions { if ap.Type == "free_shipping" && !ap.Pending { hasFreeShipping = true break } } } total := cart.NewPrice() total.Add(*grain.CartState.TotalPrice) // Delivery lines — use the configured default tax rate for the purchase country. defaultKlarnaRate := defaultKlarnaTaxRate(tp, country) for _, d := range grain.Deliveries { if d == nil { continue } unitPrice := d.Price.IncVat taxAmount := d.Price.TotalVat() if hasFreeShipping { unitPrice = 0 taxAmount = 0 } if unitPrice <= 0 && !hasFreeShipping { continue } //total.Add(d.Price) lines = append(lines, &Line{ Type: "shipping_fee", Reference: d.Provider, Name: "Delivery", Quantity: 1, UnitPrice: int(unitPrice), TaxRate: defaultKlarnaRate, QuantityUnit: "st", TotalAmount: int(unitPrice), TotalTaxAmount: int(taxAmount), }) } order := &CheckoutOrder{ PurchaseCountry: country, PurchaseCurrency: currency, Locale: locale, OrderAmount: int(total.IncVat), OrderTaxAmount: int(total.TotalVat()), OrderLines: lines, MerchantReference1: grain.Id.String(), MerchantURLS: &CheckoutMerchantURLS{ Terms: fmt.Sprintf("%s/terms", meta.SiteUrl), 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), }, } payload, err := json.Marshal(order) if err != nil { return nil, nil, fmt.Errorf("marshal checkout order: %w", err) } return payload, order, nil } // defaultKlarnaTaxRate returns the default tax rate for the purchase country, in Klarna // format (percent x 100, e.g. 2500 = 25.00 %). This matches the platform basis-point // scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls back to 2500. func defaultKlarnaTaxRate(tp tax.Provider, country string) int { if tp == nil { return 2500 } return tp.DefaultTaxRate(country) } // defaultAdyenTaxRate returns the default tax rate for the purchase country, in Adyen // format (taxPercentage in basis points, e.g. 2500 = 25 %). This matches the platform // basis-point scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls // back to 2500. func defaultAdyenTaxRate(tp tax.Provider, country string) int64 { if tp == nil { return 2500 } return int64(tp.DefaultTaxRate(country)) } 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: siteUrl, CallbackBaseUrl: checkoutCallbackBaseURL, Country: country, Currency: getCurrency(country), Locale: getLocale(country), } } func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) (*adyenCheckout.CreateCheckoutSessionRequest, error) { if grain == nil { return nil, fmt.Errorf("nil grain") } if meta == nil { return nil, fmt.Errorf("nil checkout meta") } currency := meta.Currency if currency == "" { currency = "SEK" } country := meta.Country if country == "" { country = "SE" } lineItems := make([]adyenCheckout.LineItem, 0, len(grain.CartState.Items)+len(grain.Deliveries)) // Item lines for _, it := range grain.CartState.Items { if it == nil { continue } lineItems = append(lineItems, adyenCheckout.LineItem{ Quantity: common.PtrInt64(int64(it.Quantity)), AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat.Int64()), Description: common.PtrString(it.Meta.Name), AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat().Int64()), TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat().Int64()), TaxPercentage: common.PtrInt64(int64(it.Tax)), }) } hasFreeShipping := false if grain.CartState != nil { for _, ap := range grain.CartState.AppliedPromotions { if ap.Type == "free_shipping" && !ap.Pending { hasFreeShipping = true break } } } total := cart.NewPrice() total.Add(*grain.CartState.TotalPrice) // Delivery lines — use the configured default tax rate for the purchase country. defaultAdyenRate := defaultAdyenTaxRate(tp, country) for _, d := range grain.Deliveries { if d == nil { continue } amountIncTax := d.Price.IncVat.Int64() amountExTax := d.Price.ValueExVat().Int64() if hasFreeShipping { amountIncTax = 0 amountExTax = 0 } if amountIncTax <= 0 && !hasFreeShipping { continue } lineItems = append(lineItems, adyenCheckout.LineItem{ Quantity: common.PtrInt64(1), AmountIncludingTax: common.PtrInt64(amountIncTax), Description: common.PtrString("Delivery"), AmountExcludingTax: common.PtrInt64(amountExTax), TaxPercentage: common.PtrInt64(defaultAdyenRate), }) } return &adyenCheckout.CreateCheckoutSessionRequest{ Reference: grain.Id.String(), Amount: adyenCheckout.Amount{ Value: total.IncVat.Int64(), Currency: currency, }, CountryCode: common.PtrString(country), MerchantAccount: "ElgigantenECOM", Channel: common.PtrString("Web"), ShopperIP: common.PtrString(meta.ClientIp), ReturnUrl: fmt.Sprintf("%s/payment/adyen/return", meta.SiteUrl), LineItems: lineItems, }, nil }