package ucp import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "time" "git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/mats/go-cart-actor/pkg/cart" checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/timestamppb" ) // CheckoutServer is the UCP REST adapter over the checkout grain pool. type CheckoutServer struct { applier CheckoutApplier orderSvc OrderApplier // optional; when set creates real orders on complete } // NewCheckoutServer builds a UCP REST adapter over a checkout grain pool. func NewCheckoutServer(applier CheckoutApplier, orderSvc ...OrderApplier) *CheckoutServer { s := &CheckoutServer{applier: applier} if len(orderSvc) > 0 { s.orderSvc = orderSvc[0] } return s } // --------------------------------------------------------------------------- // UCP Checkout endpoints // --------------------------------------------------------------------------- // handleCreateCheckout handles POST /checkout-sessions. // // Initiates a checkout session from a cart. The cart id is required. func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Request) { var req CreateCheckoutRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } items := req.Items if len(items) == 0 && len(req.LineItems) > 0 { items = req.LineItems } var cartIdStr string if req.CartId != "" { cartIdStr = req.CartId } else if len(items) > 0 { // Fetch cart base URL cartBaseURL := os.Getenv("CART_INTERNAL_URL") if cartBaseURL == "" { cartBaseURL = "http://cart:8080" } // Prepare POST request to /ucp/v1/carts/ to create and seed the cart createReqBody := CreateCartRequest{ Items: items, } bodyBytes, err := json.Marshal(createReqBody) if err != nil { writeError(w, http.StatusInternalServerError, "failed to marshal create cart request: "+err.Error()) return } client := &http.Client{Timeout: 10 * time.Second} cartReq, err := http.NewRequestWithContext(r.Context(), "POST", fmt.Sprintf("%s/ucp/v1/carts/", cartBaseURL), bytes.NewReader(bodyBytes)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create post cart request: "+err.Error()) return } cartReq.Header.Set("Content-Type", "application/json") profile := os.Getenv("UCP_AGENT_PROFILE") if profile == "" { profile = DefaultUCPAgentProfile } cartReq.Header.Set("UCP-Agent", `profile="`+profile+`"`) cartResp, err := client.Do(cartReq) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create implicit cart: "+err.Error()) return } defer cartResp.Body.Close() if cartResp.StatusCode != http.StatusCreated && cartResp.StatusCode != http.StatusOK { writeError(w, http.StatusBadRequest, fmt.Sprintf("cart service returned status: %d when creating implicit cart", cartResp.StatusCode)) return } var cartRespBody CartResponse if err := json.NewDecoder(cartResp.Body).Decode(&cartRespBody); err != nil { writeError(w, http.StatusInternalServerError, "failed to decode implicit cart response: "+err.Error()) return } cartIdStr = cartRespBody.Id } else { writeError(w, http.StatusBadRequest, "cartId or items/line_items is required") return } cartId, ok := cart.ParseCartId(cartIdStr) if !ok { writeError(w, http.StatusBadRequest, "invalid cartId") return } // Create a new checkout session id. checkoutId, err := cart.NewCartId() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate checkout id") return } // Fetch cart state from cart service cartBaseURL := os.Getenv("CART_INTERNAL_URL") if cartBaseURL == "" { cartBaseURL = "http://cart:8080" } url := fmt.Sprintf("%s/cart/byid/%s", cartBaseURL, cartId.String()) client := &http.Client{Timeout: 10 * time.Second} cartReq, err := http.NewRequestWithContext(r.Context(), "GET", url, nil) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create cart request: "+err.Error()) return } profile := os.Getenv("UCP_AGENT_PROFILE") if profile == "" { profile = DefaultUCPAgentProfile } cartReq.Header.Set("UCP-Agent", `profile="`+profile+`"`) cartResp, err := client.Do(cartReq) if err != nil { writeError(w, http.StatusInternalServerError, "failed to fetch cart: "+err.Error()) return } defer cartResp.Body.Close() if cartResp.StatusCode != http.StatusOK { writeError(w, http.StatusBadRequest, fmt.Sprintf("cart service returned status: %d", cartResp.StatusCode)) return } cartStateBytes, err := io.ReadAll(cartResp.Body) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read cart state: "+err.Error()) return } // Initialize checkout with cart reference and state. initMsg := &checkoutMessages.InitializeCheckout{ OrderId: "", CartId: uint64(cartId), CartState: &anypb.Any{ TypeUrl: "type.googleapis.com/cart.CartGrain", Value: cartStateBytes, }, } if _, err := s.applier.Apply(r.Context(), uint64(checkoutId), initMsg); err != nil { writeError(w, http.StatusInternalServerError, "failed to initialize checkout: "+err.Error()) return } g, err := s.applier.Get(r.Context(), uint64(checkoutId)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error()) return } writeJSON(w, http.StatusCreated, checkoutGrainToResponse(checkoutId.String(), g)) } // handleGetCheckout handles GET /checkout-sessions/{id}. func (s *CheckoutServer) handleGetCheckout(w http.ResponseWriter, r *http.Request) { id, ok := parseSessionID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid checkout session id") return } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "checkout session not found") return } writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g)) } // handleUpdateCheckout handles PUT /checkout-sessions/{id}. // // Updates checkout with delivery, contact, or pickup point info. func (s *CheckoutServer) handleUpdateCheckout(w http.ResponseWriter, r *http.Request) { id, ok := parseSessionID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid checkout session id") return } var req UpdateCheckoutRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } // Apply delivery. if req.Delivery != nil { msg := &checkoutMessages.SetDelivery{ Provider: req.Delivery.Provider, } for _, itemId := range req.Delivery.ItemIds { msg.Items = append(msg.Items, itemId) } if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { writeError(w, http.StatusInternalServerError, "failed to set delivery: "+err.Error()) return } } // Apply pickup point. if req.PickupPoint != nil { pp := &checkoutMessages.SetPickupPoint{ DeliveryId: req.PickupPoint.DeliveryId, PickupPoint: &checkoutMessages.PickupPoint{ Id: req.PickupPoint.Id, Name: req.PickupPoint.Name, }, } if _, err := s.applier.Apply(r.Context(), id, pp); err != nil { writeError(w, http.StatusInternalServerError, "failed to set pickup point: "+err.Error()) return } } // Apply contact details. if req.Contact != nil { msg := &checkoutMessages.ContactDetailsUpdated{ Email: req.Contact.Email, Phone: req.Contact.Phone, Name: req.Contact.Name, PostalCode: req.Contact.PostalCode, } if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { writeError(w, http.StatusInternalServerError, "failed to update contact details: "+err.Error()) return } } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error()) return } writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g)) } // handleCompleteCheckout handles POST /checkout-sessions/{id}/complete. // // Completes the checkout session and creates an order. When an OrderApplier // is configured on the server, a real order is created by calling the order // service. Otherwise, a simplified order reference is generated locally. func (s *CheckoutServer) handleCompleteCheckout(w http.ResponseWriter, r *http.Request) { id, ok := parseSessionID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid checkout session id") return } var req CompleteCheckoutRequest json.NewDecoder(r.Body).Decode(&req) // best-effort g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "checkout session not found") return } // Default currency/country from the request or fall back to cart/cart state. currency := req.Currency country := req.Country if currency == "" && g.CartState != nil { currency = g.CartState.Currency } if currency == "" { currency = "SEK" } var orderID string if s.orderSvc != nil { // Real order creation via the configured OrderApplier. orderID, err = s.orderSvc.CreateOrder(r.Context(), id, g, req.Provider, req.PaymentToken, currency, country) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create order: "+err.Error()) return } } else { // Fallback: generate a local order reference (simplified). var ref cart.CartId ref, err = cart.NewCartId() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate order reference") return } orderID = ref.String() } // Record the order on the checkout grain. if _, err := s.applier.Apply(r.Context(), id, &checkoutMessages.OrderCreated{ OrderId: orderID, CreatedAt: timestamppb.New(time.Now()), }, ); err != nil { writeError(w, http.StatusInternalServerError, "failed to record order: "+err.Error()) return } g, err = s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error()) return } writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g)) } // handleCancelCheckout handles POST /checkout-sessions/{id}/cancel. // // Cancels the checkout session. func (s *CheckoutServer) handleCancelCheckout(w http.ResponseWriter, r *http.Request) { id, ok := parseSessionID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid checkout session id") return } // Cancel all active payments. for { g, err := s.applier.Get(r.Context(), id) if err != nil { break } openPayments := g.OpenPayments() if len(openPayments) == 0 { break } for _, p := range openPayments { s.applier.Apply(r.Context(), id, &checkoutMessages.CancelPayment{ PaymentId: p.PaymentId, CancelledAt: timestamppb.New(time.Now()), }) } } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "checkout session not found") return } writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g)) } // --------------------------------------------------------------------------- // OrderHTTPClient — an OrderApplier backed by the order service HTTP API // --------------------------------------------------------------------------- // OrderHTTPClient implements OrderApplier by POSTing to an order service's // /api/orders/from-checkout endpoint. It mirrors the existing -> order handoff // used by the checkout service's own Klarna/Adyen callbacks. type OrderHTTPClient struct { baseURL string httpClient *http.Client agentProfile string // UCP-Agent profile URI } // NewOrderHTTPClient returns an order applier that creates orders by calling // the order service at baseURL (e.g. "http://order-service:8092"). It reads // the UCP_AGENT_PROFILE env var for the profile URI, falling back to // DefaultUCPAgentProfile from the ucp package. func NewOrderHTTPClient(baseURL string) *OrderHTTPClient { profile := os.Getenv("UCP_AGENT_PROFILE") if profile == "" { profile = DefaultUCPAgentProfile } return &OrderHTTPClient{ baseURL: baseURL, httpClient: &http.Client{Timeout: 30 * time.Second}, agentProfile: profile, } } // fromCheckoutReq mirrors cmd/order/handlers_checkout.go's internal type. type fromCheckoutReq struct { CheckoutId string `json:"checkoutId"` IdempotencyKey string `json:"idempotencyKey"` CartId string `json:"cartId"` Currency string `json:"currency"` Country string `json:"country"` CustomerEmail string `json:"customerEmail,omitempty"` CustomerName string `json:"customerName,omitempty"` Lines []orderLine `json:"lines"` Payment orderPayment `json:"payment"` } type orderLine struct { Reference string `json:"reference"` Sku string `json:"sku"` Name string `json:"name"` Quantity int32 `json:"quantity"` UnitPrice int64 `json:"unitPrice"` TaxRate int32 `json:"taxRate"` } type orderPayment struct { Provider string `json:"provider"` Reference string `json:"reference"` Amount int64 `json:"amount"` } // CreateOrder implements OrderApplier. It builds a from-checkout request from // the checkout grain's state and POSTs it to the order service. func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g *checkout.CheckoutGrain, provider, reference, currency, country string) (string, error) { if g.CartState == nil { return "", fmt.Errorf("order: checkout %d has no cart state", checkoutID) } var customerEmail, customerName string if g.ContactDetails != nil { if g.ContactDetails.Email != nil { customerEmail = *g.ContactDetails.Email } if g.ContactDetails.Name != nil { customerName = *g.ContactDetails.Name } } // Compute total from cart items + deliveries. var totalAmount int64 lines := buildOrderLines(g) for _, l := range lines { totalAmount += l.UnitPrice * int64(l.Quantity) } req := fromCheckoutReq{ CheckoutId: fmt.Sprintf("%d", checkoutID), IdempotencyKey: fmt.Sprintf("ucp-checkout-%d", checkoutID), CartId: g.CartId.String(), Currency: currency, Country: country, CustomerEmail: customerEmail, CustomerName: customerName, Lines: lines, Payment: orderPayment{ Provider: provider, Reference: reference, Amount: totalAmount, }, } body, err := json.Marshal(req) if err != nil { return "", fmt.Errorf("order: marshal: %w", err) } url := c.baseURL + "/api/orders/from-checkout" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("order: new request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") // Advertise this platform's UCP profile on all outgoing requests. WithUCPAgentOnRequest(httpReq, c.agentProfile) resp, err := c.httpClient.Do(httpReq) if err != nil { return "", fmt.Errorf("order: do: %w", err) } defer resp.Body.Close() // Both 201 and 409 (idempotent hit) are valid. if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict { return "", fmt.Errorf("order: %s", resp.Status) } var result struct { OrderId string `json:"orderId"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("order: decode: %w", err) } if result.OrderId == "" { return "", fmt.Errorf("order: empty orderId in response") } return result.OrderId, nil } // buildOrderLines converts a checkout grain's cart items and delivery selections // into order lines, matching the format expected by the order service. func buildOrderLines(g *checkout.CheckoutGrain) []orderLine { lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries)) for _, it := range g.CartState.Items { if it == nil { continue } name := "" if it.Meta != nil { name = it.Meta.Name } lines = append(lines, orderLine{ Reference: it.Sku, Sku: it.Sku, Name: name, Quantity: int32(it.Quantity), UnitPrice: it.Price.IncVat, TaxRate: int32(it.Tax), }) } for _, d := range g.Deliveries { if d == nil || d.Price.IncVat <= 0 { continue } lines = append(lines, orderLine{ Reference: d.Provider, Sku: d.Provider, Name: "Delivery", Quantity: 1, UnitPrice: d.Price.IncVat, TaxRate: 2500, }) } return lines } // --------------------------------------------------------------------------- // Helper: parse session id from path // --------------------------------------------------------------------------- func parseSessionID(r *http.Request) (uint64, bool) { raw := r.PathValue("id") if raw == "" { return 0, false } cid, ok := cart.ParseCartId(raw) if !ok { return 0, false } return uint64(cid), true } // --------------------------------------------------------------------------- // Helper: convert CheckoutGrain → UCP CheckoutResponse // --------------------------------------------------------------------------- func checkoutGrainToResponse(id string, g *checkout.CheckoutGrain) CheckoutResponse { resp := CheckoutResponse{ Id: id, CartId: g.CartId.String(), Status: "active", Deliveries: make([]DeliveryResponse, 0, len(g.Deliveries)), Payments: make([]PaymentResponse, 0, len(g.Payments)), Totals: Totals{Currency: "SEK"}, } if g.OrderId != nil && *g.OrderId != "" { resp.OrderId = g.OrderId resp.Status = "completed" } if g.CartState != nil { for _, it := range g.CartState.Items { if it == nil { continue } item := CartItem{ Sku: it.Sku, Quantity: int(it.Quantity), UnitPrice: it.Price.IncVat, TotalPrice: it.TotalPrice.IncVat, TaxRate: it.Tax, } if it.Meta != nil { item.Name = it.Meta.Name item.Image = it.Meta.Image } resp.Items = append(resp.Items, item) } if g.CartState.TotalPrice != nil { resp.Totals.TotalIncVat = g.CartState.TotalPrice.IncVat resp.Totals.TotalExVat = g.CartState.TotalPrice.ValueExVat() resp.Totals.TotalVat = g.CartState.TotalPrice.TotalVat() } } for _, d := range g.Deliveries { if d == nil { continue } dr := DeliveryResponse{ Id: d.Id, Provider: d.Provider, Price: d.Price.IncVat, Items: d.Items, } if d.PickupPoint != nil { dr.PickupPoint = &PickupPointResp{ Id: d.PickupPoint.Id, Name: d.PickupPoint.Name, Address: d.PickupPoint.Address, City: d.PickupPoint.City, Zip: d.PickupPoint.Zip, } } resp.Deliveries = append(resp.Deliveries, dr) } if g.ContactDetails != nil { resp.Contact = &ContactResponse{ Email: g.ContactDetails.Email, Phone: g.ContactDetails.Phone, Name: g.ContactDetails.Name, PostalCode: g.ContactDetails.PostalCode, } } for _, p := range g.Payments { if p == nil { continue } resp.Payments = append(resp.Payments, PaymentResponse{ Id: p.PaymentId, Provider: p.Provider, Amount: p.Amount, Currency: p.Currency, Status: string(p.Status), }) } return resp }