mcp and ucp
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user