Files
go-cart-actor/internal/ucp/checkout.go
T
mats 9aa587b9cf
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
mcp and ucp
2026-06-24 10:50:32 +02:00

551 lines
16 KiB
Go

package ucp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"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
}
if req.CartId == "" {
writeError(w, http.StatusBadRequest, "cartId is required")
return
}
cartId, ok := cart.ParseCartId(req.CartId)
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
}
// Initialize checkout with cart reference.
initMsg := &checkoutMessages.InitializeCheckout{
OrderId: "",
CartId: uint64(cartId),
}
if req.Country != "" {
// Pass country through the cart state snapshot (minimal).
initMsg.CartState = &anypb.Any{
TypeUrl: "request/country",
Value: []byte(req.Country),
}
}
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
}
// NewOrderHTTPClient returns an order applier that creates orders by calling
// the order service at baseURL (e.g. "http://order-service:8092").
func NewOrderHTTPClient(baseURL string) *OrderHTTPClient {
return &OrderHTTPClient{
baseURL: baseURL,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// 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")
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
}