330 lines
9.8 KiB
Go
330 lines
9.8 KiB
Go
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).Int64()
|
|
}
|
|
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
|
|
}
|