354 lines
11 KiB
Go
354 lines
11 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"
|
|
"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),
|
|
})
|
|
}
|
|
|
|
// Reflected applied promotions as negative discount lines
|
|
if grain.CartState != nil {
|
|
for _, ap := range grain.CartState.AppliedPromotions {
|
|
if ap.Pending {
|
|
continue
|
|
}
|
|
discountVal := int64(0)
|
|
if ap.Discount != nil {
|
|
discountVal = ap.Discount.IncVat.Int64()
|
|
}
|
|
if discountVal <= 0 {
|
|
continue
|
|
}
|
|
lines = append(lines, &Line{
|
|
Type: "discount",
|
|
Reference: "promo-" + ap.PromotionId,
|
|
Name: "Promotion: " + ap.Name,
|
|
Quantity: 1,
|
|
UnitPrice: int(-discountVal),
|
|
QuantityUnit: "st",
|
|
TotalAmount: int(-discountVal),
|
|
TaxRate: 2500,
|
|
TotalTaxAmount: int(-discountVal * 2500 / 12500),
|
|
})
|
|
}
|
|
}
|
|
|
|
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),
|
|
})
|
|
}
|
|
|
|
// Reflected applied promotions as negative discount lines
|
|
if grain.CartState != nil {
|
|
for _, ap := range grain.CartState.AppliedPromotions {
|
|
if ap.Pending {
|
|
continue
|
|
}
|
|
discountVal := int64(0)
|
|
if ap.Discount != nil {
|
|
discountVal = ap.Discount.IncVat.Int64()
|
|
}
|
|
if discountVal <= 0 {
|
|
continue
|
|
}
|
|
lineItems = append(lineItems, adyenCheckout.LineItem{
|
|
Quantity: common.PtrInt64(1),
|
|
AmountIncludingTax: common.PtrInt64(-discountVal),
|
|
Description: common.PtrString("Promotion: " + ap.Name),
|
|
AmountExcludingTax: common.PtrInt64(-discountVal * 10000 / 12500),
|
|
TaxAmount: common.PtrInt64(-discountVal * 2500 / 12500),
|
|
TaxPercentage: common.PtrInt64(2500),
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
}
|