package main import ( "context" "encoding/json" "fmt" "net/http" "os" "time" "git.k6n.net/mats/go-cart-actor/pkg/flow" "git.k6n.net/mats/go-cart-actor/pkg/order" messages "git.k6n.net/mats/go-cart-actor/proto/order" "google.golang.org/protobuf/proto" ) func nowMs() int64 { return time.Now().UnixMilli() } // applyOne applies a single mutation and surfaces the handler error (the pool // returns a top-level error only for unregistered mutations; a rejected // transition is carried per-mutation in the result). func applyOne(ctx context.Context, app *orderedApplier, id uint64, msg proto.Message) error { res, err := app.Apply(ctx, id, msg) if err != nil { return err } if res != nil { for _, m := range res.Mutations { if m.Error != nil { return m.Error } } } return nil } // capturedRef returns the capture reference of the first captured payment, which // the payment provider needs to issue a refund against. func capturedRef(g *order.OrderGrain) string { for _, p := range g.Payments { if p.Captured > 0 && p.CaptureRef != "" { return p.CaptureRef } } return "" } // loadOrder parses the id and returns the current grain, writing 400/404 itself // and returning ok=false when the caller should stop. func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderId, *order.OrderGrain, bool) { id, ok := order.ParseOrderId(r.PathValue("id")) if !ok { writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id")) return 0, nil, false } g, err := s.applier.Get(r.Context(), uint64(id)) if err != nil { writeErr(w, http.StatusInternalServerError, err) return 0, nil, false } if g.Status == order.StatusNew { writeErr(w, http.StatusNotFound, fmt.Errorf("order not found")) return 0, nil, false } return id, g, true } // applyAndRespond applies a mutation and returns the updated order. A rejected // state transition (handler error) maps to 409 Conflict. func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message) { res, err := s.applier.Apply(r.Context(), uint64(id), msg) if err != nil { writeErr(w, http.StatusInternalServerError, err) return } for _, m := range res.Mutations { if m.Error != nil { writeErr(w, http.StatusConflict, m.Error) return } } grain, err := s.applier.Get(r.Context(), uint64(id)) if err != nil { writeErr(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain}) } // --- lifecycle (b) -------------------------------------------------------- type fulfillReq 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"` } func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) { id, g, ok := s.loadOrder(w, r) if !ok { return } var req fulfillReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, err) return } fid, _ := order.NewOrderId() carrier := req.Carrier trackingNumber := req.TrackingNumber trackingURI := req.TrackingURI // Integration with go-shipping: if carrier == "" && g.CartId != "" { shippingURL := os.Getenv("SHIPPING_URL") if shippingURL == "" { shippingURL = "http://localhost:8080" } // Query go-shipping for cached options url := fmt.Sprintf("%s/api/shipping-options/%s", shippingURL, g.CartId) ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err == nil { resp, err := http.DefaultClient.Do(httpReq) if err == nil && resp.StatusCode == http.StatusOK { var groups []struct { Type string `json:"type"` DefaultOption *struct { BookingInstructions struct { DeliveryOptionID string `json:"deliveryOptionId"` ServiceCode string `json:"serviceCode"` } `json:"bookingInstructions"` DescriptiveTexts struct { Checkout struct { Title string `json:"title"` } `json:"checkout"` } `json:"descriptiveTexts"` } `json:"defaultOption"` } if json.NewDecoder(resp.Body).Decode(&groups) == nil && len(groups) > 0 { g0 := groups[0] carrier = g0.Type if g0.DefaultOption != nil { opt := g0.DefaultOption if opt.DescriptiveTexts.Checkout.Title != "" { carrier = opt.DescriptiveTexts.Checkout.Title } trackingNumber = "SE-" + opt.BookingInstructions.DeliveryOptionID trackingURI = "https://www.postnord.se/en/our-tools/track-and-trace?shipmentId=" + trackingNumber } } resp.Body.Close() } } } if carrier == "" { carrier = "postnord" } if trackingNumber == "" { trackingNumber = "mock-track-" + fid.String() } if trackingURI == "" { trackingURI = "https://tracking.postnord.com/?id=" + trackingNumber } msg := &messages.CreateFulfillment{ Id: "f_" + fid.String(), Carrier: carrier, TrackingNumber: trackingNumber, TrackingUri: trackingURI, AtMs: nowMs(), } for _, l := range req.Lines { msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}) } s.applyAndRespond(w, r, id, msg) } type exchangeReq struct { Reason string `json:"reason,omitempty"` ReturnLines []struct { Reference string `json:"reference"` Quantity int32 `json:"quantity"` } `json:"returnLines"` NewLines []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"` TotalAmount int64 `json:"totalAmount"` TotalTax int64 `json:"totalTax"` } `json:"newLines"` } func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) { id, _, ok := s.loadOrder(w, r) if !ok { return } var req exchangeReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, err) return } exId, _ := order.NewOrderId() retId, _ := order.NewOrderId() msg := &messages.RequestExchange{ Id: "ex_" + exId.String(), ReturnId: "r_" + retId.String(), Reason: req.Reason, AtMs: nowMs(), } for _, l := range req.ReturnLines { msg.ReturnLines = append(msg.ReturnLines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}) } for _, l := range req.NewLines { msg.NewLines = append(msg.NewLines, &messages.OrderLine{ Reference: l.Reference, Sku: l.Sku, Name: l.Name, Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate, TotalAmount: l.TotalAmount, TotalTax: l.TotalTax, }) } s.applyAndRespond(w, r, id, msg) } type editDetailsReq struct { ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"` BillingAddress json.RawMessage `json:"billingAddress,omitempty"` ShippingPrice int64 `json:"shippingPrice,omitempty"` } func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) { id, _, ok := s.loadOrder(w, r) if !ok { return } var req editDetailsReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, err) return } msg := &messages.EditOrderDetails{ ShippingAddress: req.ShippingAddress, BillingAddress: req.BillingAddress, ShippingPrice: req.ShippingPrice, AtMs: nowMs(), } s.applyAndRespond(w, r, id, msg) } func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) { id, _, ok := s.loadOrder(w, r) if !ok { return } s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()}) } func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) { id, _, ok := s.loadOrder(w, r) if !ok { return } var req struct { Reason string `json:"reason"` } _ = json.NewDecoder(r.Body).Decode(&req) s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()}) } type returnReq struct { Reason string `json:"reason,omitempty"` Lines []struct { Reference string `json:"reference"` Quantity int32 `json:"quantity"` } `json:"lines"` } func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) { id, _, ok := s.loadOrder(w, r) if !ok { return } var req returnReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, err) return } rid, _ := order.NewOrderId() msg := &messages.RequestReturn{Id: "r_" + rid.String(), Reason: req.Reason, AtMs: nowMs()} for _, l := range req.Lines { msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}) } s.applyAndRespond(w, r, id, msg) } func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) { id, g, ok := s.loadOrder(w, r) if !ok { return } var req struct { Amount int64 `json:"amount"` } _ = json.NewDecoder(r.Body).Decode(&req) amount := req.Amount if amount <= 0 { amount = g.CapturedAmount - g.RefundedAmount // default: full remaining } captureRef := capturedRef(g) ref, err := s.provider.Refund(r.Context(), captureRef, amount) if err != nil { writeErr(w, http.StatusBadGateway, fmt.Errorf("refund failed: %w", err)) return } s.applyAndRespond(w, r, id, &messages.IssueRefund{ Provider: ref.Provider, Amount: ref.Amount, Reference: ref.Reference, AtMs: nowMs(), }) } // --- saga / flow admin (a) ------------------------------------------------ func (s *server) handleCapabilities(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, s.freg.Capabilities()) } func (s *server) handleListFlows(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, s.flows.list()) } func (s *server) handleGetFlow(w http.ResponseWriter, r *http.Request) { def, ok := s.flows.get(r.PathValue("name")) if !ok { writeErr(w, http.StatusNotFound, fmt.Errorf("flow not found")) return } writeJSON(w, http.StatusOK, def) } func (s *server) handleSaveFlow(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var def flow.Definition if err := json.NewDecoder(r.Body).Decode(&def); err != nil { writeErr(w, http.StatusBadRequest, err) return } if err := s.flows.save(name, &def); err != nil { // Validation failures (unknown action/hook/predicate) are client errors. writeErr(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusOK, &def) }