package ucp import ( "encoding/json" "net/http" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/order" orderMessages "git.k6n.net/mats/go-cart-actor/proto/order" ) // OrderServer is the UCP REST adapter over the order grain pool. type OrderServer struct { applier OrderReadApplier } // NewOrderServer builds a UCP REST adapter over an order grain pool. func NewOrderServer(applier OrderReadApplier) *OrderServer { return &OrderServer{applier: applier} } // --------------------------------------------------------------------------- // UCP Order endpoints // --------------------------------------------------------------------------- // handleGetOrder handles GET /orders/{id}. func (s *OrderServer) handleGetOrder(w http.ResponseWriter, r *http.Request) { g, err := s.loadOrder(w, r) if err != nil || g == nil { return } writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g)) } // handleCancelOrder handles POST /orders/{id}/cancel. func (s *OrderServer) handleCancelOrder(w http.ResponseWriter, r *http.Request) { g, err := s.loadOrder(w, r) if err != nil || g == nil { return } var req struct { Reason string `json:"reason,omitempty"` } _ = json.NewDecoder(r.Body).Decode(&req) res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.CancelOrder{ Reason: req.Reason, AtMs: time.Now().UnixMilli(), }) if applyErr != nil || res == nil || mutationRejected(res) { msg := "failed to cancel order" if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil { msg += ": " + res.Mutations[0].Error.Error() } else if applyErr != nil { msg += ": " + applyErr.Error() } writeError(w, http.StatusUnprocessableEntity, msg) return } updated, _ := s.applier.Get(r.Context(), g.Id) if updated != nil { writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated)) } else { writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g)) } } // handleCreateFulfillment handles POST /orders/{id}/fulfillments. func (s *OrderServer) handleCreateFulfillment(w http.ResponseWriter, r *http.Request) { g, err := s.loadOrder(w, r) if err != nil || g == nil { return } var req struct { Carrier string `json:"carrier,omitempty"` TrackingNumber string `json:"trackingNumber,omitempty"` TrackingURI string `json:"trackingUri,omitempty"` Lines []struct { Reference string `json:"reference"` Quantity int32 `json:"quantity"` } `json:"lines,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } fid, _ := order.NewOrderId() msg := &orderMessages.CreateFulfillment{ Id: "f_" + fid.String(), Carrier: req.Carrier, TrackingNumber: req.TrackingNumber, TrackingUri: req.TrackingURI, AtMs: time.Now().UnixMilli(), } for _, l := range req.Lines { msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}) } res, applyErr := s.applier.Apply(r.Context(), g.Id, msg) if applyErr != nil || res == nil || mutationRejected(res) { msg := "failed to create fulfillment" if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil { msg += ": " + res.Mutations[0].Error.Error() } else if applyErr != nil { msg += ": " + applyErr.Error() } writeError(w, http.StatusUnprocessableEntity, msg) return } updated, _ := s.applier.Get(r.Context(), g.Id) writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated)) } // handleRequestReturn handles POST /orders/{id}/returns. func (s *OrderServer) handleRequestReturn(w http.ResponseWriter, r *http.Request) { g, err := s.loadOrder(w, r) if err != nil || g == nil { return } var req struct { Reason string `json:"reason,omitempty"` Lines []struct { Reference string `json:"reference"` Quantity int32 `json:"quantity"` } `json:"lines,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } rid, _ := order.NewOrderId() msg := &orderMessages.RequestReturn{ Id: "r_" + rid.String(), Reason: req.Reason, AtMs: time.Now().UnixMilli(), } for _, l := range req.Lines { msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}) } res, applyErr := s.applier.Apply(r.Context(), g.Id, msg) if applyErr != nil || res == nil || mutationRejected(res) { msg := "failed to request return" if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil { msg += ": " + res.Mutations[0].Error.Error() } else if applyErr != nil { msg += ": " + applyErr.Error() } writeError(w, http.StatusUnprocessableEntity, msg) return } updated, _ := s.applier.Get(r.Context(), g.Id) writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated)) } // handleIssueRefund handles POST /orders/{id}/refunds. func (s *OrderServer) handleIssueRefund(w http.ResponseWriter, r *http.Request) { g, err := s.loadOrder(w, r) if err != nil || g == nil { return } var req struct { Amount int64 `json:"amount,omitempty"` // minor units; omit for full remaining } _ = json.NewDecoder(r.Body).Decode(&req) amount := req.Amount if amount <= 0 { amount = g.CapturedAmount - g.RefundedAmount } if amount <= 0 { writeError(w, http.StatusBadRequest, "no captured amount to refund") return } idStr := order.OrderId(g.Id).String() res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.IssueRefund{ Provider: "ucp", Amount: amount, Reference: "ref-ucp-" + idStr, AtMs: time.Now().UnixMilli(), }) if applyErr != nil || res == nil || mutationRejected(res) { msg := "failed to issue refund" if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil { msg += ": " + res.Mutations[0].Error.Error() } else if applyErr != nil { msg += ": " + applyErr.Error() } writeError(w, http.StatusUnprocessableEntity, msg) return } updated, _ := s.applier.Get(r.Context(), g.Id) writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated)) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // mutationRejected returns true if the Apply result indicates the mutation was // rejected (business-rule violation rather than infrastructure error). func mutationRejected(res *actor.MutationResult[order.OrderGrain]) bool { return len(res.Mutations) > 0 && res.Mutations[0].Error != nil } // loadOrder parses the id from the path and returns the grain, or writes an // error response and returns nil. func (s *OrderServer) loadOrder(w http.ResponseWriter, r *http.Request) (*order.OrderGrain, error) { raw := r.PathValue("id") if raw == "" { writeError(w, http.StatusBadRequest, "missing order id") return nil, nil } id, ok := order.ParseOrderId(raw) if !ok { writeError(w, http.StatusBadRequest, "invalid order id") return nil, nil } g, err := s.applier.Get(r.Context(), uint64(id)) if err != nil { writeError(w, http.StatusNotFound, "order not found") return nil, nil } if g.Status == order.StatusNew { writeError(w, http.StatusNotFound, "order not found") return nil, nil } return g, nil } // orderGrainToResponse converts an OrderGrain to an OrderResponse. func orderGrainToResponse(id string, g *order.OrderGrain) OrderResponse { resp := OrderResponse{ OrderId: id, Reference: g.OrderReference, Status: string(g.Status), Currency: g.Currency, TotalAmount: g.TotalAmount, TotalTax: g.TotalTax, Lines: make([]OrderLineResp, 0, len(g.Lines)), Payments: make([]OrderPaymentResp, 0, len(g.Payments)), Fulfillments: make([]OrderFulfillmentResp, 0, len(g.Fulfillments)), Returns: make([]OrderReturnResp, 0, len(g.Returns)), Refunds: make([]OrderRefundResp, 0, len(g.Refunds)), } for _, l := range g.Lines { resp.Lines = append(resp.Lines, OrderLineResp{ Reference: l.Reference, Sku: l.Sku, Name: l.Name, Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate, TotalAmount: l.TotalAmount, TotalTax: l.TotalTax, Fulfilled: l.Fulfilled, }) } for _, p := range g.Payments { if p == nil { continue } resp.Payments = append(resp.Payments, OrderPaymentResp{ Provider: p.Provider, Authorized: p.Authorized, Captured: p.Captured, Refunded: p.Refunded, AuthRef: p.AuthRef, CaptureRef: p.CaptureRef, }) } for _, f := range g.Fulfillments { lines := make([]OrderLineEntry, 0, len(f.Lines)) for _, l := range f.Lines { lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity}) } resp.Fulfillments = append(resp.Fulfillments, OrderFulfillmentResp{ Id: f.ID, Carrier: f.Carrier, TrackingNumber: f.TrackingNumber, TrackingURI: f.TrackingURI, Lines: lines, }) } for _, r := range g.Returns { lines := make([]OrderLineEntry, 0, len(r.Lines)) for _, l := range r.Lines { lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity}) } resp.Returns = append(resp.Returns, OrderReturnResp{ Id: r.ID, Reason: r.Reason, Lines: lines, }) } for _, r := range g.Refunds { resp.Refunds = append(resp.Refunds, OrderRefundResp{ Provider: r.Provider, Amount: r.Amount, Reference: r.Reference, }) } resp.CapturedAmount = g.CapturedAmount resp.RefundedAmount = g.RefundedAmount resp.CustomerEmail = g.CustomerEmail resp.CustomerName = g.CustomerName resp.CartId = g.CartId resp.PlacedAt = g.PlacedAt resp.UpdatedAt = g.UpdatedAt return resp }