444 lines
12 KiB
Go
444 lines
12 KiB
Go
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
|
|
}
|
|
|
|
// checkIdempotency checks if the request is an idempotent retry.
|
|
// It returns (idemKey, exists, unlockFn).
|
|
// If exists is true, the caller should write the current order grain status and return immediately.
|
|
// If exists is false, the caller must defer unlockFn() and continue.
|
|
func (s *server) checkIdempotency(w http.ResponseWriter, r *http.Request, id order.OrderId) (string, bool, func()) {
|
|
idemKey := r.Header.Get("Idempotency-Key")
|
|
if idemKey == "" {
|
|
return "", false, func() {}
|
|
}
|
|
unlock := s.idem.Lock(idemKey)
|
|
if _, ok := s.idem.Get(idemKey); ok {
|
|
unlock() // Release lock immediately since we are returning cached response
|
|
s.logger.Info("idempotency hit for lifecycle endpoint", "key", idemKey, "orderId", id.String())
|
|
grain, err := s.applier.Get(r.Context(), uint64(id))
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err)
|
|
return idemKey, true, nil
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
|
|
return idemKey, true, nil
|
|
}
|
|
return idemKey, false, unlock
|
|
}
|
|
|
|
// 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, idemKey string) {
|
|
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
|
|
}
|
|
}
|
|
if idemKey != "" {
|
|
if err := s.idem.Put(idemKey, uint64(id)); err != nil {
|
|
s.logger.Error("idempotency record failed", "key", idemKey, "orderId", id.String(), "err", err)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
|
if exists {
|
|
return
|
|
}
|
|
defer unlock()
|
|
|
|
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, idemKey)
|
|
}
|
|
|
|
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
|
|
}
|
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
|
if exists {
|
|
return
|
|
}
|
|
defer unlock()
|
|
|
|
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, idemKey)
|
|
}
|
|
|
|
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
|
|
}
|
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
|
if exists {
|
|
return
|
|
}
|
|
defer unlock()
|
|
|
|
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, idemKey)
|
|
}
|
|
|
|
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
|
|
id, _, ok := s.loadOrder(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
|
if exists {
|
|
return
|
|
}
|
|
defer unlock()
|
|
|
|
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()}, idemKey)
|
|
}
|
|
|
|
func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
|
|
id, _, ok := s.loadOrder(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
|
if exists {
|
|
return
|
|
}
|
|
defer unlock()
|
|
|
|
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()}, idemKey)
|
|
}
|
|
|
|
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
|
|
}
|
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
|
if exists {
|
|
return
|
|
}
|
|
defer unlock()
|
|
|
|
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, idemKey)
|
|
}
|
|
|
|
func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
|
|
id, g, ok := s.loadOrder(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
|
if exists {
|
|
return
|
|
}
|
|
defer unlock()
|
|
|
|
var req struct {
|
|
Amount int64 `json:"amount"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
amount := req.Amount
|
|
if amount <= 0 {
|
|
amount = (g.CapturedAmount - g.RefundedAmount).Int64() // 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(),
|
|
}, idemKey)
|
|
}
|
|
|
|
// --- 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)
|
|
}
|