Files
go-cart-actor/cmd/checkout/checkout_builder.go
T
mats 0252893de5
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
klarna and checkouts
2026-06-26 15:41:55 +02:00

239 lines
7.8 KiB
Go

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"
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.
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]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),
})
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
//total.Add(d.Price)
lines = append(lines, &Line{
Type: "shipping_fee",
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price.IncVat),
TaxRate: 2500,
QuantityUnit: "st",
TotalAmount: int(d.Price.IncVat),
TotalTaxAmount: int(d.Price.TotalVat()),
})
}
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
}
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) (*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),
Description: common.PtrString(it.Meta.Name),
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
TaxPercentage: common.PtrInt64(int64(it.Tax)),
})
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(1),
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
Description: common.PtrString("Delivery"),
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
TaxPercentage: common.PtrInt64(25),
})
}
return &adyenCheckout.CreateCheckoutSessionRequest{
Reference: grain.Id.String(),
Amount: adyenCheckout.Amount{
Value: total.IncVat,
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
}