diff --git a/Dockerfile b/Dockerfile
index 4345143..d73707b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -85,6 +85,12 @@ RUN go build -trimpath -ldflags="-s -w \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-checkout-actor ./cmd/checkout
+RUN go build -trimpath -ldflags="-s -w \
+ -X main.Version=${VERSION} \
+ -X main.GitCommit=${GIT_COMMIT} \
+ -X main.BuildDate=${BUILD_DATE}" \
+ -o /out/go-order-actor ./cmd/order
+
############################
# Runtime Stage
############################
@@ -96,6 +102,7 @@ COPY --from=build /out/go-cart-actor /go-cart-actor
COPY --from=build /out/go-checkout-actor /go-checkout-actor
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
COPY --from=build /out/go-cart-inventory /go-cart-inventory
+COPY --from=build /out/go-order-actor /go-order-actor
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
EXPOSE 8080 1337
diff --git a/Makefile b/Makefile
index 70579e4..019caaf 100644
--- a/Makefile
+++ b/Makefile
@@ -19,10 +19,11 @@
MODULE_PATH := git.k6n.net/mats/go-cart-actor
PROTO_DIR := proto
-PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto
+PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto
CART_PROTO_DIR := $(PROTO_DIR)/cart
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
+ORDER_PROTO_DIR := $(PROTO_DIR)/order
# Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc
@@ -83,6 +84,10 @@ protogen: check_tools
--go_out=./proto/checkout --go_opt=paths=source_relative \
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/checkout.proto
+ $(PROTOC) -I $(PROTO_DIR) \
+ --go_out=./proto/order --go_opt=paths=source_relative \
+ --go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \
+ $(PROTO_DIR)/order.proto
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto:
diff --git a/cmd/order/amqp.go b/cmd/order/amqp.go
new file mode 100644
index 0000000..33d8cb4
--- /dev/null
+++ b/cmd/order/amqp.go
@@ -0,0 +1,190 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "hash/fnv"
+ "log/slog"
+ "sync"
+ "time"
+
+ messages "git.k6n.net/mats/go-cart-actor/proto/order"
+
+ amqp "github.com/rabbitmq/amqp091-go"
+)
+
+// rabbitPublisher implements order.Publisher for the amqp_emit flow hook.
+type rabbitPublisher struct {
+ conn *amqp.Connection
+ ch *amqp.Channel
+ mu sync.Mutex
+}
+
+func newRabbitPublisher(url string) (*rabbitPublisher, error) {
+ conn, err := amqp.Dial(url)
+ if err != nil {
+ return nil, err
+ }
+ ch, err := conn.Channel()
+ if err != nil {
+ _ = conn.Close()
+ return nil, err
+ }
+ return &rabbitPublisher{conn: conn, ch: ch}, nil
+}
+
+func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.ch.PublishWithContext(context.Background(), exchange, routingKey, false, false,
+ amqp.Publishing{ContentType: "application/json", Body: body})
+}
+
+// This ingester lets the order service supersede the legacy go-order-manager:
+// it consumes the same "order-queue" the Klarna/Adyen checkout publishes to and
+// records each completed order as an event-sourced grain. Those orders are
+// already paid by the external processor, so we record payment with provider
+// "legacy" (place -> authorize -> capture) rather than charging again.
+
+// legacyOrder is the subset of go-order-manager's Order JSON we need.
+type legacyOrder struct {
+ ID string `json:"order_id"`
+ PurchaseCountry string `json:"purchase_country"`
+ PurchaseCurrency string `json:"purchase_currency"`
+ Locale string `json:"locale"`
+ OrderAmount int64 `json:"order_amount"`
+ OrderTaxAmount int64 `json:"order_tax_amount"`
+ OrderLines []legacyLine `json:"order_lines"`
+ Customer *legacyPerson `json:"customer,omitempty"`
+ BillingAddress *legacyAddr `json:"billing_address,omitempty"`
+}
+
+type legacyLine struct {
+ Reference string `json:"reference"`
+ Name string `json:"name"`
+ Quantity int32 `json:"quantity"`
+ UnitPrice int64 `json:"unit_price"`
+ TaxRate int32 `json:"tax_rate"`
+ TotalAmount int64 `json:"total_amount"`
+ TotalTaxAmount int64 `json:"total_tax_amount"`
+}
+
+type legacyPerson struct {
+ Email string `json:"email,omitempty"`
+}
+type legacyAddr struct {
+ Email string `json:"email,omitempty"`
+ GivenName string `json:"given_name,omitempty"`
+ FamilyName string `json:"family_name,omitempty"`
+}
+
+// legacyOrderID maps a legacy string order id to a stable grain id, so a
+// redelivered message resolves to the same grain (PlaceOrder then no-ops).
+func legacyOrderID(ref string) uint64 {
+ h := fnv.New64a()
+ _, _ = h.Write([]byte(ref))
+ id := h.Sum64()
+ if id == 0 {
+ id = 1
+ }
+ return id
+}
+
+// ingestLegacyOrder records one legacy order as an event-sourced grain. It is
+// idempotent: if the order is already placed, PlaceOrder is rejected and we
+// stop (the order already exists). Testable without a broker.
+func ingestLegacyOrder(ctx context.Context, app *orderedApplier, body []byte) error {
+ var lo legacyOrder
+ if err := json.Unmarshal(body, &lo); err != nil {
+ return err
+ }
+ if lo.ID == "" || len(lo.OrderLines) == 0 {
+ return nil // nothing actionable
+ }
+ id := legacyOrderID(lo.ID)
+ ms := time.Now().UnixMilli()
+
+ po := &messages.PlaceOrder{
+ OrderReference: lo.ID,
+ Currency: lo.PurchaseCurrency,
+ Locale: lo.Locale,
+ Country: lo.PurchaseCountry,
+ TotalAmount: lo.OrderAmount,
+ TotalTax: lo.OrderTaxAmount,
+ PlacedAtMs: ms,
+ }
+ if lo.Customer != nil {
+ po.CustomerEmail = lo.Customer.Email
+ }
+ if po.CustomerEmail == "" && lo.BillingAddress != nil {
+ po.CustomerEmail = lo.BillingAddress.Email
+ po.CustomerName = lo.BillingAddress.GivenName + " " + lo.BillingAddress.FamilyName
+ }
+ for _, l := range lo.OrderLines {
+ po.Lines = append(po.Lines, &messages.OrderLine{
+ Reference: l.Reference, Sku: l.Reference, Name: l.Name,
+ Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate,
+ TotalAmount: l.TotalAmount, TotalTax: l.TotalTaxAmount,
+ })
+ }
+
+ if err := applyOne(ctx, app, id, po); err != nil {
+ // Already placed (redelivery) or invalid — nothing more to do.
+ return nil
+ }
+ // Record the externally-settled payment so the grain reaches "captured".
+ ref := "legacy-" + lo.ID
+ _ = applyOne(ctx, app, id, &messages.AuthorizePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
+ _ = applyOne(ctx, app, id, &messages.CapturePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
+ return nil
+}
+
+// startLegacyIngest connects to AMQP and consumes "order-queue" until ctx is
+// done. Best-effort: a connection failure is logged and ingestion is skipped
+// (the HTTP checkout path keeps working regardless).
+func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier, logger *slog.Logger) {
+ conn, err := amqp.Dial(amqpURL)
+ if err != nil {
+ logger.Warn("legacy order ingest disabled: amqp dial failed", "err", err)
+ return
+ }
+ ch, err := conn.Channel()
+ if err != nil {
+ logger.Warn("legacy order ingest disabled: channel failed", "err", err)
+ _ = conn.Close()
+ return
+ }
+ q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
+ if err != nil {
+ logger.Warn("legacy order ingest disabled: queue declare failed", "err", err)
+ _ = ch.Close()
+ _ = conn.Close()
+ return
+ }
+ msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
+ if err != nil {
+ logger.Warn("legacy order ingest disabled: consume failed", "err", err)
+ _ = ch.Close()
+ _ = conn.Close()
+ return
+ }
+ logger.Info("ingesting legacy orders from order-queue")
+ go func() {
+ defer conn.Close()
+ defer ch.Close()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case d, ok := <-msgs:
+ if !ok {
+ logger.Warn("legacy order ingest: channel closed")
+ return
+ }
+ if err := ingestLegacyOrder(ctx, app, d.Body); err != nil {
+ logger.Error("legacy order ingest failed", "err", err)
+ }
+ }
+ }
+ }()
+}
diff --git a/cmd/order/amqp_test.go b/cmd/order/amqp_test.go
new file mode 100644
index 0000000..2693bce
--- /dev/null
+++ b/cmd/order/amqp_test.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "git.k6n.net/mats/go-cart-actor/pkg/actor"
+ "git.k6n.net/mats/go-cart-actor/pkg/order"
+)
+
+func testApplier(t *testing.T) *orderedApplier {
+ t.Helper()
+ reg := actor.NewMutationRegistry()
+ order.RegisterMutations(reg)
+ storage := actor.NewDiskStorage[order.OrderGrain](t.TempDir(), reg)
+ pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
+ Hostname: "test",
+ Spawn: func(_ context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
+ return order.NewOrderGrain(id, time.Now()), nil
+ },
+ SpawnHost: func(string) (actor.Host[order.OrderGrain], error) { return nil, fmt.Errorf("none") },
+ Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
+ TTL: time.Hour,
+ PoolSize: 100,
+
+ MutationRegistry: reg,
+ Storage: nil,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ return &orderedApplier{pool: pool, storage: storage}
+}
+
+func TestIngestLegacyOrder(t *testing.T) {
+ app := testApplier(t)
+ ctx := context.Background()
+ body := []byte(`{
+ "order_id":"K-1","purchase_currency":"SEK","purchase_country":"se","locale":"sv-SE",
+ "order_amount":15000,"order_tax_amount":3000,
+ "order_lines":[{"reference":"l1","name":"Widget","quantity":1,"unit_price":15000,"tax_rate":25,"total_amount":15000,"total_tax_amount":3000}]
+ }`)
+
+ if err := ingestLegacyOrder(ctx, app, body); err != nil {
+ t.Fatalf("ingest: %v", err)
+ }
+ id := legacyOrderID("K-1")
+ g, err := app.Get(ctx, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if g.Status != order.StatusCaptured {
+ t.Fatalf("status = %q, want captured", g.Status)
+ }
+ if g.CapturedAmount != 15000 {
+ t.Fatalf("captured = %d, want 15000", g.CapturedAmount)
+ }
+
+ // Redelivery must be idempotent — no double capture, no new payment.
+ if err := ingestLegacyOrder(ctx, app, body); err != nil {
+ t.Fatalf("re-ingest: %v", err)
+ }
+ g2, _ := app.Get(ctx, id)
+ if g2.CapturedAmount != 15000 {
+ t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
+ }
+ if len(g2.Payments) != 1 {
+ t.Fatalf("redelivery created %d payments, want 1", len(g2.Payments))
+ }
+}
diff --git a/cmd/order/flows.go b/cmd/order/flows.go
new file mode 100644
index 0000000..f67b744
--- /dev/null
+++ b/cmd/order/flows.go
@@ -0,0 +1,111 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+
+ "git.k6n.net/mats/go-cart-actor/pkg/flow"
+)
+
+// flowStore holds the saga/flow definitions the service can run. Definitions
+// are JSON files in a directory (editable by the backoffice saga editor),
+// overlaid on a set of built-in seeds so a fresh deployment always has the
+// defaults. Saving validates against the engine before writing.
+type flowStore struct {
+ mu sync.RWMutex
+ dir string
+ engine *flow.Engine
+ defs map[string]*flow.Definition
+}
+
+var validFlowName = func(s string) bool {
+ if s == "" || len(s) > 64 {
+ return false
+ }
+ for _, r := range s {
+ if !(r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
+ return false
+ }
+ }
+ return true
+}
+
+// newFlowStore seeds with the built-in definitions, then overlays any JSON files
+// found in dir (on-disk wins, so edits persist and take effect).
+func newFlowStore(dir string, engine *flow.Engine, seed map[string]*flow.Definition) (*flowStore, error) {
+ s := &flowStore{dir: dir, engine: engine, defs: map[string]*flow.Definition{}}
+ for name, def := range seed {
+ s.defs[name] = def
+ }
+ matches, _ := filepath.Glob(filepath.Join(dir, "*.json"))
+ for _, m := range matches {
+ data, err := os.ReadFile(m)
+ if err != nil {
+ return nil, fmt.Errorf("read flow %s: %w", m, err)
+ }
+ def, err := flow.Parse(data)
+ if err != nil {
+ return nil, fmt.Errorf("parse flow %s: %w", m, err)
+ }
+ if def.Name == "" {
+ def.Name = strings.TrimSuffix(filepath.Base(m), ".json")
+ }
+ s.defs[def.Name] = def
+ }
+ return s, nil
+}
+
+func (s *flowStore) get(name string) (*flow.Definition, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ d, ok := s.defs[name]
+ return d, ok
+}
+
+func (s *flowStore) list() []*flow.Definition {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ names := make([]string, 0, len(s.defs))
+ for n := range s.defs {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ out := make([]*flow.Definition, 0, len(names))
+ for _, n := range names {
+ out = append(out, s.defs[n])
+ }
+ return out
+}
+
+// save validates the definition, persists it to
/.json and updates
+// the in-memory map so the next checkout uses it.
+func (s *flowStore) save(name string, def *flow.Definition) error {
+ if !validFlowName(name) {
+ return fmt.Errorf("invalid flow name %q", name)
+ }
+ if def.Name == "" {
+ def.Name = name
+ }
+ if err := s.engine.Validate(def); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(def, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(s.dir, 0o755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(s.dir, name+".json"), append(data, '\n'), 0o644); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ s.defs[name] = def
+ s.mu.Unlock()
+ return nil
+}
diff --git a/cmd/order/handlers.go b/cmd/order/handlers.go
new file mode 100644
index 0000000..f14377a
--- /dev/null
+++ b/cmd/order/handlers.go
@@ -0,0 +1,231 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "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, _, 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()
+ msg := &messages.CreateFulfillment{
+ Id: "f_" + fid.String(),
+ Carrier: req.Carrier,
+ TrackingNumber: req.TrackingNumber,
+ TrackingUri: req.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)
+}
+
+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)
+}
diff --git a/cmd/order/main.go b/cmd/order/main.go
new file mode 100644
index 0000000..7752f5c
--- /dev/null
+++ b/cmd/order/main.go
@@ -0,0 +1,444 @@
+// Command order is a single-node HTTP entrypoint for the order grain. It exposes
+// a simple checkout that creates an order by running the place-and-pay flow
+// (place -> mock authorize -> capture) on the actor framework, plus read APIs
+// for an order and its event timeline. No clustering, no external payment — the
+// simplest path to a real, event-sourced, captured order.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "git.k6n.net/mats/go-cart-actor/pkg/actor"
+ "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/encoding/protojson"
+ "google.golang.org/protobuf/proto"
+)
+
+type server struct {
+ pool *actor.SimpleGrainPool[order.OrderGrain]
+ applier *orderedApplier
+ storage *actor.DiskStorage[order.OrderGrain]
+ reg actor.MutationRegistry
+ engine *flow.Engine
+ freg *flow.Registry
+ flows *flowStore
+ provider order.PaymentProvider
+ defaultFlow string
+ dataDir string
+ logger *slog.Logger
+}
+
+func main() {
+ addr := envOr("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector
+ dataDir := envOr("ORDER_DATA", "data/orders")
+ flowsDir := envOr("ORDER_FLOWS", "data/order-flows")
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ log.Fatalf("create data dir: %v", err)
+ }
+ logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
+
+ reg := actor.NewMutationRegistry()
+ order.RegisterMutations(reg)
+ storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg)
+
+ pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
+ Hostname: "order-1",
+ Spawn: func(ctx context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
+ g := order.NewOrderGrain(id, time.Now())
+ // Replay any persisted history so the resident grain is current.
+ if err := storage.LoadEvents(ctx, id, g); err != nil {
+ return nil, err
+ }
+ return g, nil
+ },
+ SpawnHost: func(string) (actor.Host[order.OrderGrain], error) {
+ return nil, fmt.Errorf("order service is single-node")
+ },
+ Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
+ TTL: time.Hour,
+ PoolSize: 10000,
+ // Storage is intentionally nil here: the pool persists mutations in a
+ // fire-and-forget goroutine, so concurrent appends for one grain can land
+ // out of order on disk — fatal for an event log, where replay order is the
+ // state. We persist through orderedApplier instead (synchronous, ordered,
+ // and only for mutations that actually applied). Replay on spawn still uses
+ // `storage` directly in Spawn above.
+ MutationRegistry: reg,
+ Storage: nil,
+ })
+ if err != nil {
+ log.Fatalf("create pool: %v", err)
+ }
+
+ applier := &orderedApplier{pool: pool, storage: storage}
+ provider := selectProvider(logger)
+
+ // Flow registry: generic hooks + the order lifecycle actions + predicates,
+ // paying via the selected provider. The amqp_emit hook bridges saga steps to
+ // the broker (publisher is nil without AMQP — the hook then errors at run
+ // time, which is logged, not fatal).
+ amqpURL := os.Getenv("AMQP_URL")
+ var emitPub order.Publisher
+ if amqpURL != "" {
+ if pub, err := newRabbitPublisher(amqpURL); err != nil {
+ logger.Warn("amqp_emit hook: publisher unavailable", "err", err)
+ } else {
+ emitPub = pub
+ }
+ }
+ freg := flow.NewRegistry()
+ flow.RegisterBuiltinHooks(freg)
+ order.RegisterFlowActions(freg, applier, provider)
+ order.RegisterEmitHook(freg, emitPub)
+ engine := flow.NewEngine(freg, logger)
+
+ def, err := order.EmbeddedFlow("place-and-pay")
+ if err != nil {
+ log.Fatalf("load flow: %v", err)
+ }
+ flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{def.Name: def})
+ if err != nil {
+ log.Fatalf("load flows: %v", err)
+ }
+
+ s := &server{
+ pool: pool, applier: applier, storage: storage, reg: reg,
+ engine: engine, freg: freg, flows: flows, provider: provider,
+ defaultFlow: def.Name, dataDir: dataDir, logger: logger,
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
+
+ // Checkout + order reads.
+ mux.HandleFunc("POST /checkout", s.handleCheckout)
+ mux.HandleFunc("GET /api/orders", s.handleList)
+ mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
+ mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
+
+ // Post-purchase lifecycle (the grain already supports these transitions).
+ mux.HandleFunc("POST /api/orders/{id}/fulfillments", s.handleFulfill)
+ mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete)
+ mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel)
+ mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
+ mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
+
+ // Saga / flow admin (backoffice editor).
+ mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
+ mux.HandleFunc("GET /sagas/flows", s.handleListFlows)
+ mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
+ mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
+
+ // Ingest legacy checkout orders (Klarna/Adyen → order-queue) so this service
+ // is the single source of orders, superseding go-order-manager.
+ if amqpURL != "" {
+ startLegacyIngest(context.Background(), amqpURL, applier, logger)
+ }
+
+ logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
+ if err := http.ListenAndServe(addr, mux); err != nil {
+ log.Fatalf("serve: %v", err)
+ }
+}
+
+// orderedApplier wraps the grain pool to persist the event log correctly: it
+// appends each applied mutation synchronously and in call order (the pool's own
+// storage path is async/unordered), and only persists mutations whose handler
+// succeeded — a rejected transition must never enter the log. A single mutex
+// serialises all appends, which is ample for a single-node checkout service.
+type orderedApplier struct {
+ pool *actor.SimpleGrainPool[order.OrderGrain]
+ storage *actor.DiskStorage[order.OrderGrain]
+ mu sync.Mutex
+}
+
+func (a *orderedApplier) Get(ctx context.Context, id uint64) (*order.OrderGrain, error) {
+ return a.pool.Get(ctx, id)
+}
+
+func (a *orderedApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) {
+ res, err := a.pool.Apply(ctx, id, mutation...)
+ if err != nil || res == nil {
+ return res, err
+ }
+ // Persist only the mutations that actually applied (result index aligns with
+ // the input order).
+ applied := make([]proto.Message, 0, len(mutation))
+ for i, m := range mutation {
+ if i < len(res.Mutations) && res.Mutations[i].Error != nil {
+ continue
+ }
+ applied = append(applied, m)
+ }
+ if len(applied) > 0 {
+ a.mu.Lock()
+ appendErr := a.storage.AppendMutations(id, applied...)
+ a.mu.Unlock()
+ if appendErr != nil {
+ return res, appendErr
+ }
+ }
+ return res, nil
+}
+
+// --- checkout -------------------------------------------------------------
+
+type lineReq struct {
+ Reference string `json:"reference"`
+ Sku string `json:"sku"`
+ Name string `json:"name"`
+ Quantity int32 `json:"quantity"`
+ UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
+ TaxRate int32 `json:"taxRate"` // percent
+}
+
+type checkoutReq struct {
+ OrderReference string `json:"orderReference,omitempty"`
+ CartId string `json:"cartId,omitempty"`
+ Currency string `json:"currency,omitempty"`
+ Locale string `json:"locale,omitempty"`
+ Country string `json:"country,omitempty"`
+ CustomerEmail string `json:"customerEmail,omitempty"`
+ CustomerName string `json:"customerName,omitempty"`
+ BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
+ ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
+ Lines []lineReq `json:"lines"`
+}
+
+func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
+ var req checkoutReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeErr(w, http.StatusBadRequest, fmt.Errorf("bad body: %w", err))
+ return
+ }
+ if len(req.Lines) == 0 {
+ writeErr(w, http.StatusBadRequest, fmt.Errorf("checkout requires at least one line"))
+ return
+ }
+
+ id, err := order.NewOrderId()
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, err)
+ return
+ }
+ if req.OrderReference == "" {
+ req.OrderReference = "ord-" + id.String()
+ }
+ if req.Currency == "" {
+ req.Currency = "SEK"
+ }
+
+ flowName := r.URL.Query().Get("flow")
+ if flowName == "" {
+ flowName = s.defaultFlow
+ }
+ def, ok := s.flows.get(flowName)
+ if !ok {
+ writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName))
+ return
+ }
+
+ po := buildPlaceOrder(id, &req)
+
+ st := flow.NewState(uint64(id), s.logger)
+ st.Vars[order.PlaceOrderVar] = po
+
+ res, runErr := s.engine.Run(r.Context(), def, st)
+ grain, getErr := s.pool.Get(r.Context(), uint64(id))
+ if getErr != nil {
+ writeErr(w, http.StatusInternalServerError, getErr)
+ return
+ }
+
+ status := http.StatusCreated
+ if runErr != nil {
+ // The flow compensated; the order exists but is not paid. Report 402 with
+ // the (now cancelled/failed) order and the flow trace so the caller knows.
+ status = http.StatusPaymentRequired
+ s.logger.Warn("checkout flow failed", "orderId", id.String(), "err", runErr)
+ }
+ writeJSON(w, status, map[string]any{
+ "orderId": id.String(),
+ "flow": res,
+ "order": grain,
+ })
+}
+
+// buildPlaceOrder converts a checkout request into the PlaceOrder event,
+// computing per-line and order totals (inc-vat) from the lines.
+func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
+ po := &messages.PlaceOrder{
+ OrderReference: req.OrderReference,
+ CartId: req.CartId,
+ Currency: req.Currency,
+ Locale: req.Locale,
+ Country: req.Country,
+ CustomerEmail: req.CustomerEmail,
+ CustomerName: req.CustomerName,
+ BillingAddress: req.BillingAddress,
+ ShippingAddress: req.ShippingAddress,
+ PlacedAtMs: time.Now().UnixMilli(),
+ }
+ var total, totalTax int64
+ for _, l := range req.Lines {
+ lineTotal := l.UnitPrice * int64(l.Quantity)
+ var lineTax int64
+ if l.TaxRate > 0 {
+ // inc-vat -> tax portion = total * rate / (100 + rate)
+ lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
+ }
+ total += lineTotal
+ totalTax += lineTax
+ po.Lines = append(po.Lines, &messages.OrderLine{
+ Reference: l.Reference,
+ Sku: l.Sku,
+ Name: l.Name,
+ Quantity: l.Quantity,
+ UnitPrice: l.UnitPrice,
+ TaxRate: l.TaxRate,
+ TotalAmount: lineTotal,
+ TotalTax: lineTax,
+ })
+ }
+ po.TotalAmount = total
+ po.TotalTax = totalTax
+ return po
+}
+
+// --- reads ----------------------------------------------------------------
+
+func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
+ id, ok := order.ParseOrderId(r.PathValue("id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
+ return
+ }
+ grain, err := s.pool.Get(r.Context(), uint64(id))
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, err)
+ return
+ }
+ if grain.Status == order.StatusNew {
+ writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
+}
+
+type eventView struct {
+ Type string `json:"type"`
+ Time string `json:"time,omitempty"`
+ Payload json.RawMessage `json:"payload,omitempty"`
+}
+
+// handleEvents returns the raw event timeline by reading the grain's append-only
+// log (the source of truth) without re-applying it to live state.
+func (s *server) handleEvents(w http.ResponseWriter, r *http.Request) {
+ id, ok := order.ParseOrderId(r.PathValue("id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
+ return
+ }
+ var events []eventView
+ throwaway := order.NewOrderGrain(uint64(id), time.Now())
+ err := s.storage.LoadEventsFunc(r.Context(), uint64(id), throwaway,
+ func(msg proto.Message, _ int, ts time.Time) bool {
+ name, _ := s.reg.GetTypeName(msg)
+ payload, _ := protojson.Marshal(msg)
+ events = append(events, eventView{Type: name, Time: ts.UTC().Format(time.RFC3339), Payload: payload})
+ return false // collect only; do not apply
+ })
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "events": events})
+}
+
+type orderSummary struct {
+ OrderId string `json:"orderId"`
+ Reference string `json:"reference,omitempty"`
+ Status order.Status `json:"status"`
+ TotalAmount int64 `json:"totalAmount"`
+ CapturedAmount int64 `json:"capturedAmount"`
+ Currency string `json:"currency,omitempty"`
+ PlacedAt string `json:"placedAt,omitempty"`
+}
+
+// handleList scans the data dir for order logs and summarises each (replaying it
+// through the pool). Fine for a single-node simple checkout; a production list
+// would use an index/projection store.
+func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
+ matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
+ out := make([]orderSummary, 0, len(matches))
+ for _, m := range matches {
+ base := strings.TrimSuffix(filepath.Base(m), ".events.log")
+ raw, err := strconv.ParseUint(base, 10, 64)
+ if err != nil {
+ continue
+ }
+ g, err := s.pool.Get(r.Context(), raw)
+ if err != nil || g.Status == order.StatusNew {
+ continue
+ }
+ out = append(out, orderSummary{
+ OrderId: order.OrderId(raw).String(),
+ Reference: g.OrderReference,
+ Status: g.Status,
+ TotalAmount: g.TotalAmount,
+ CapturedAmount: g.CapturedAmount,
+ Currency: g.Currency,
+ PlacedAt: g.PlacedAt,
+ })
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// --- helpers --------------------------------------------------------------
+
+// selectProvider picks the payment provider from the environment. Default is
+// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
+// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
+func selectProvider(logger *slog.Logger) order.PaymentProvider {
+ if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
+ key := os.Getenv("STRIPE_SECRET_KEY")
+ if key == "" {
+ logger.Warn("PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is empty; falling back to mock")
+ return order.NewMockProvider()
+ }
+ logger.Info("using stripe payment provider")
+ return order.NewStripeProvider(key, os.Getenv("STRIPE_API_BASE"), os.Getenv("STRIPE_PAYMENT_METHOD"))
+ }
+ return order.NewMockProvider()
+}
+
+func envOr(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func writeErr(w http.ResponseWriter, status int, err error) {
+ writeJSON(w, status, map[string]string{"error": err.Error()})
+}
diff --git a/pkg/flow/engine.go b/pkg/flow/engine.go
new file mode 100644
index 0000000..168c6e0
--- /dev/null
+++ b/pkg/flow/engine.go
@@ -0,0 +1,301 @@
+package flow
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "sort"
+ "sync"
+)
+
+// Phase identifies when a hook fires relative to its step's action.
+type Phase string
+
+const (
+ PhaseBefore Phase = "before"
+ PhaseAfter Phase = "after"
+ PhaseOnError Phase = "onError"
+)
+
+// State is the mutable bag shared across a single flow run. Actions and hooks
+// read and write Vars to pass data down the chain (e.g. an authorization
+// reference produced by one step and consumed by the next). ID is the domain
+// entity the flow operates on (an order id, for the order flows).
+type State struct {
+ ID uint64
+ Vars map[string]any
+ Logger *slog.Logger
+}
+
+// NewState returns an empty state for entity id.
+func NewState(id uint64, logger *slog.Logger) *State {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &State{ID: id, Vars: map[string]any{}, Logger: logger}
+}
+
+// Action performs a step's work. params is the step's opaque JSON config.
+type Action func(ctx context.Context, st *State, params json.RawMessage) error
+
+// Predicate gates a step (the step runs only when it returns true).
+type Predicate func(ctx context.Context, st *State) (bool, error)
+
+// HookInfo describes the step and phase a hook is firing for.
+type HookInfo struct {
+ Step string
+ Phase Phase
+ // Err is the action error when Phase == PhaseOnError, else nil.
+ Err error
+}
+
+// Hook reacts around a step. Hook errors never abort the flow — they are logged
+// — so observability/notification hooks can't break the business transaction.
+type Hook func(ctx context.Context, st *State, info HookInfo, params json.RawMessage) error
+
+// Registry holds the named actions, predicates and hooks a flow can reference.
+type Registry struct {
+ mu sync.RWMutex
+ actions map[string]Action
+ predicates map[string]Predicate
+ hooks map[string]Hook
+}
+
+// NewRegistry returns an empty registry.
+func NewRegistry() *Registry {
+ return &Registry{
+ actions: map[string]Action{},
+ predicates: map[string]Predicate{},
+ hooks: map[string]Hook{},
+ }
+}
+
+// Action registers an action under name (overwrites an existing one).
+func (r *Registry) Action(name string, fn Action) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.actions[name] = fn
+}
+
+// Predicate registers a predicate under name.
+func (r *Registry) Predicate(name string, fn Predicate) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.predicates[name] = fn
+}
+
+// Hook registers a hook under name.
+func (r *Registry) Hook(name string, fn Hook) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.hooks[name] = fn
+}
+
+func (r *Registry) action(name string) (Action, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ a, ok := r.actions[name]
+ return a, ok
+}
+
+func (r *Registry) predicate(name string) (Predicate, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ p, ok := r.predicates[name]
+ return p, ok
+}
+
+func (r *Registry) hook(name string) (Hook, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ h, ok := r.hooks[name]
+ return h, ok
+}
+
+// Capabilities is the set of registered names, surfaced so an editor UI can
+// offer exactly the actions/predicates/hooks a flow may reference.
+type Capabilities struct {
+ Actions []string `json:"actions"`
+ Predicates []string `json:"predicates"`
+ Hooks []string `json:"hooks"`
+}
+
+// Capabilities returns the sorted registered names.
+func (r *Registry) Capabilities() Capabilities {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return Capabilities{
+ Actions: sortedKeys(r.actions),
+ Predicates: sortedKeys(r.predicates),
+ Hooks: sortedKeys(r.hooks),
+ }
+}
+
+func sortedKeys[V any](m map[string]V) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// StepResult records the outcome of one step.
+type StepResult struct {
+ Name string `json:"name"`
+ Skipped bool `json:"skipped,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// Result is the outcome of a whole flow run.
+type Result struct {
+ Flow string `json:"flow"`
+ Steps []StepResult `json:"steps"`
+ Compensated []string `json:"compensated,omitempty"`
+ Failed bool `json:"failed"`
+}
+
+// Engine runs flow definitions against a registry.
+type Engine struct {
+ reg *Registry
+ logger *slog.Logger
+}
+
+// NewEngine returns an engine bound to reg.
+func NewEngine(reg *Registry, logger *slog.Logger) *Engine {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &Engine{reg: reg, logger: logger}
+}
+
+// Validate checks that every action, predicate and hook a definition references
+// is registered. Call it at load time to fail fast on a bad flow config.
+func (e *Engine) Validate(def *Definition) error {
+ for _, s := range def.Steps {
+ if _, ok := e.reg.action(s.Action); !ok {
+ return fmt.Errorf("flow %q step %q: unknown action %q", def.Name, s.Name, s.Action)
+ }
+ if s.When != "" {
+ if _, ok := e.reg.predicate(s.When); !ok {
+ return fmt.Errorf("flow %q step %q: unknown predicate %q", def.Name, s.Name, s.When)
+ }
+ }
+ if s.Compensate != nil {
+ if _, ok := e.reg.action(s.Compensate.Action); !ok {
+ return fmt.Errorf("flow %q step %q: unknown compensate action %q", def.Name, s.Name, s.Compensate.Action)
+ }
+ }
+ for _, hs := range [][]HookRef{s.Hooks.Before, s.Hooks.After, s.Hooks.OnError} {
+ for _, h := range hs {
+ if _, ok := e.reg.hook(h.Type); !ok {
+ return fmt.Errorf("flow %q step %q: unknown hook %q", def.Name, s.Name, h.Type)
+ }
+ }
+ }
+ }
+ return nil
+}
+
+// Run executes the flow. On a step error (without ContinueOnError) it fires the
+// step's on-error hooks, compensates every already-executed step that declared a
+// compensating action (in reverse), and returns the error. Hook failures are
+// logged, never fatal.
+func (e *Engine) Run(ctx context.Context, def *Definition, st *State) (*Result, error) {
+ res := &Result{Flow: def.Name}
+ var executed []Step // steps that completed, for reverse compensation
+
+ for _, step := range def.Steps {
+ if step.When != "" {
+ pred, ok := e.reg.predicate(step.When)
+ if !ok {
+ return res, e.fail(res, step, fmt.Errorf("unknown predicate %q", step.When))
+ }
+ run, err := pred(ctx, st)
+ if err != nil {
+ return res, e.abort(ctx, res, &executed, step, err, st)
+ }
+ if !run {
+ res.Steps = append(res.Steps, StepResult{Name: step.Name, Skipped: true})
+ st.Logger.Info("flow step skipped", "flow", def.Name, "step", step.Name)
+ continue
+ }
+ }
+
+ action, ok := e.reg.action(step.Action)
+ if !ok {
+ return res, e.fail(res, step, fmt.Errorf("unknown action %q", step.Action))
+ }
+
+ e.fireHooks(ctx, st, step.Hooks.Before, HookInfo{Step: step.Name, Phase: PhaseBefore})
+
+ err := action(ctx, st, step.Params)
+ if err != nil {
+ e.fireHooks(ctx, st, step.Hooks.OnError, HookInfo{Step: step.Name, Phase: PhaseOnError, Err: err})
+ if step.ContinueOnError {
+ res.Steps = append(res.Steps, StepResult{Name: step.Name, Error: err.Error()})
+ st.Logger.Warn("flow step failed, continuing", "flow", def.Name, "step", step.Name, "err", err)
+ executed = append(executed, step)
+ continue
+ }
+ return res, e.abort(ctx, res, &executed, step, err, st)
+ }
+
+ e.fireHooks(ctx, st, step.Hooks.After, HookInfo{Step: step.Name, Phase: PhaseAfter})
+ res.Steps = append(res.Steps, StepResult{Name: step.Name})
+ executed = append(executed, step)
+ }
+ return res, nil
+}
+
+// fail records a step error without compensation (used for config errors found
+// before the action ran, so nothing needs undoing for this step).
+func (e *Engine) fail(res *Result, step Step, err error) error {
+ res.Failed = true
+ res.Steps = append(res.Steps, StepResult{Name: step.Name, Error: err.Error()})
+ return fmt.Errorf("flow %q step %q: %w", res.Flow, step.Name, err)
+}
+
+// abort records the failing step, then compensates executed steps in reverse.
+func (e *Engine) abort(ctx context.Context, res *Result, executed *[]Step, step Step, cause error, st *State) error {
+ res.Failed = true
+ res.Steps = append(res.Steps, StepResult{Name: step.Name, Error: cause.Error()})
+ e.compensate(ctx, res, *executed, st)
+ return fmt.Errorf("flow %q step %q: %w", res.Flow, step.Name, cause)
+}
+
+// compensate runs the compensating action of each executed step in reverse.
+func (e *Engine) compensate(ctx context.Context, res *Result, executed []Step, st *State) {
+ for i := len(executed) - 1; i >= 0; i-- {
+ step := executed[i]
+ if step.Compensate == nil {
+ continue
+ }
+ action, ok := e.reg.action(step.Compensate.Action)
+ if !ok {
+ st.Logger.Error("flow compensation skipped: unknown action",
+ "flow", res.Flow, "step", step.Name, "action", step.Compensate.Action)
+ continue
+ }
+ if err := action(ctx, st, step.Compensate.Params); err != nil {
+ st.Logger.Error("flow compensation failed", "flow", res.Flow, "step", step.Name, "err", err)
+ continue
+ }
+ res.Compensated = append(res.Compensated, step.Name)
+ }
+}
+
+// fireHooks runs every hook for a phase, logging (never propagating) failures.
+func (e *Engine) fireHooks(ctx context.Context, st *State, refs []HookRef, info HookInfo) {
+ for _, ref := range refs {
+ h, ok := e.reg.hook(ref.Type)
+ if !ok {
+ st.Logger.Error("flow hook skipped: unknown type", "hook", ref.Type, "step", info.Step)
+ continue
+ }
+ if err := h(ctx, st, info, ref.Params); err != nil {
+ st.Logger.Warn("flow hook error", "hook", ref.Type, "step", info.Step, "phase", info.Phase, "err", err)
+ }
+ }
+}
diff --git a/pkg/flow/engine_test.go b/pkg/flow/engine_test.go
new file mode 100644
index 0000000..15020fa
--- /dev/null
+++ b/pkg/flow/engine_test.go
@@ -0,0 +1,91 @@
+package flow
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+)
+
+func TestRunRecordsOrderAndHooks(t *testing.T) {
+ reg := NewRegistry()
+ var trace []string
+ reg.Action("a", func(_ context.Context, _ *State, _ json.RawMessage) error {
+ trace = append(trace, "a")
+ return nil
+ })
+ reg.Action("b", func(_ context.Context, _ *State, _ json.RawMessage) error {
+ trace = append(trace, "b")
+ return nil
+ })
+ reg.Hook("rec", func(_ context.Context, _ *State, info HookInfo, _ json.RawMessage) error {
+ trace = append(trace, string(info.Phase)+":"+info.Step)
+ return nil
+ })
+
+ def := &Definition{Name: "t", Steps: []Step{
+ {Name: "s1", Action: "a", Hooks: Hooks{Before: []HookRef{{Type: "rec"}}, After: []HookRef{{Type: "rec"}}}},
+ {Name: "s2", Action: "b"},
+ }}
+ res, err := NewEngine(reg, nil).Run(context.Background(), def, NewState(1, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Failed || len(res.Steps) != 2 {
+ t.Fatalf("unexpected result %+v", res)
+ }
+ want := []string{"before:s1", "a", "after:s1", "b"}
+ if fmt.Sprint(trace) != fmt.Sprint(want) {
+ t.Fatalf("trace = %v, want %v", trace, want)
+ }
+}
+
+func TestPredicateSkipsStep(t *testing.T) {
+ reg := NewRegistry()
+ ran := false
+ reg.Action("a", func(_ context.Context, _ *State, _ json.RawMessage) error { ran = true; return nil })
+ reg.Predicate("never", func(_ context.Context, _ *State) (bool, error) { return false, nil })
+
+ def := &Definition{Name: "t", Steps: []Step{{Name: "s1", Action: "a", When: "never"}}}
+ res, err := NewEngine(reg, nil).Run(context.Background(), def, NewState(1, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ran {
+ t.Fatal("action ran despite predicate=false")
+ }
+ if !res.Steps[0].Skipped {
+ t.Fatal("step not marked skipped")
+ }
+}
+
+func TestContinueOnError(t *testing.T) {
+ reg := NewRegistry()
+ reg.Action("boom", func(_ context.Context, _ *State, _ json.RawMessage) error { return fmt.Errorf("x") })
+ reg.Action("ok", func(_ context.Context, _ *State, _ json.RawMessage) error { return nil })
+
+ def := &Definition{Name: "t", Steps: []Step{
+ {Name: "s1", Action: "boom", ContinueOnError: true},
+ {Name: "s2", Action: "ok"},
+ }}
+ res, err := NewEngine(reg, nil).Run(context.Background(), def, NewState(1, nil))
+ if err != nil {
+ t.Fatalf("continue-on-error should not abort: %v", err)
+ }
+ if res.Steps[0].Error == "" || res.Steps[1].Error != "" {
+ t.Fatalf("unexpected step results %+v", res.Steps)
+ }
+}
+
+func TestValidateCatchesUnknownRefs(t *testing.T) {
+ reg := NewRegistry()
+ reg.Action("a", func(_ context.Context, _ *State, _ json.RawMessage) error { return nil })
+ eng := NewEngine(reg, nil)
+
+ if err := eng.Validate(&Definition{Name: "t", Steps: []Step{{Name: "s", Action: "missing"}}}); err == nil {
+ t.Fatal("expected unknown-action error")
+ }
+ if err := eng.Validate(&Definition{Name: "t", Steps: []Step{{Name: "s", Action: "a"}}}); err != nil {
+ t.Fatalf("valid def rejected: %v", err)
+ }
+}
diff --git a/pkg/flow/flow.go b/pkg/flow/flow.go
new file mode 100644
index 0000000..0bce865
--- /dev/null
+++ b/pkg/flow/flow.go
@@ -0,0 +1,67 @@
+// Package flow is a generic, JSON-configurable orchestration engine. A flow is a
+// sequence of named steps; each step runs a registered action, may be gated by a
+// registered predicate, can fire registered hooks (before/after/on-error), and
+// may declare a compensating action used to roll back on failure (the saga
+// pattern). Actions, predicates and hooks are registered by name — the same
+// "interface + interchangeable impls, wired in one place" seam the CMS uses for
+// its event listeners. The flow definition itself is plain JSON, so flows live
+// alongside the app's other JSON config (schemas, blueprints) and can be edited
+// without code changes.
+package flow
+
+import "encoding/json"
+
+// Definition is a whole flow, loadable from JSON.
+type Definition struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Steps []Step `json:"steps"`
+}
+
+// Step is one unit of work in a flow.
+type Step struct {
+ // Name identifies the step in logs, hooks and results.
+ Name string `json:"name"`
+ // Action is the registered action to run.
+ Action string `json:"action"`
+ // Params is opaque JSON passed to the action.
+ Params json.RawMessage `json:"params,omitempty"`
+ // When, if set, names a registered predicate; the step is skipped when it
+ // returns false.
+ When string `json:"when,omitempty"`
+ // Hooks fire around the action.
+ Hooks Hooks `json:"hooks,omitempty"`
+ // Compensate, if set, is the action used to undo this step when a later step
+ // fails (executed in reverse order during rollback).
+ Compensate *ActionRef `json:"compensate,omitempty"`
+ // ContinueOnError records the error and proceeds instead of rolling back.
+ ContinueOnError bool `json:"continueOnError,omitempty"`
+}
+
+// ActionRef names an action and its params (used for compensation).
+type ActionRef struct {
+ Action string `json:"action"`
+ Params json.RawMessage `json:"params,omitempty"`
+}
+
+// Hooks groups the hook references that fire at each phase of a step.
+type Hooks struct {
+ Before []HookRef `json:"before,omitempty"`
+ After []HookRef `json:"after,omitempty"`
+ OnError []HookRef `json:"onError,omitempty"`
+}
+
+// HookRef names a registered hook and its params.
+type HookRef struct {
+ Type string `json:"type"`
+ Params json.RawMessage `json:"params,omitempty"`
+}
+
+// Parse loads a flow definition from JSON bytes.
+func Parse(data []byte) (*Definition, error) {
+ var d Definition
+ if err := json.Unmarshal(data, &d); err != nil {
+ return nil, err
+ }
+ return &d, nil
+}
diff --git a/pkg/flow/hooks.go b/pkg/flow/hooks.go
new file mode 100644
index 0000000..e95a759
--- /dev/null
+++ b/pkg/flow/hooks.go
@@ -0,0 +1,89 @@
+package flow
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// RegisterBuiltinHooks adds the generic hooks every deployment can use: "log"
+// (structured log line) and "webhook" (HTTP POST of the event). Domain packages
+// register their own hooks (e.g. "amqp_emit") on the same registry.
+func RegisterBuiltinHooks(reg *Registry) {
+ reg.Hook("log", logHook)
+ reg.Hook("noop", func(context.Context, *State, HookInfo, json.RawMessage) error { return nil })
+ reg.Hook("webhook", webhookHook(&http.Client{Timeout: 10 * time.Second}))
+}
+
+// logHook emits a structured log line for the step/phase. Params (optional):
+//
+// {"message": "custom text"}
+func logHook(_ context.Context, st *State, info HookInfo, params json.RawMessage) error {
+ msg := "flow hook"
+ if len(params) > 0 {
+ var p struct {
+ Message string `json:"message"`
+ }
+ if err := json.Unmarshal(params, &p); err == nil && p.Message != "" {
+ msg = p.Message
+ }
+ }
+ args := []any{"step", info.Step, "phase", info.Phase, "id", st.ID}
+ if info.Err != nil {
+ args = append(args, "err", info.Err)
+ }
+ st.Logger.Info(msg, args...)
+ return nil
+}
+
+// webhookHook POSTs a JSON event to a configured URL. Params:
+//
+// {"url": "https://...", "headers": {"X-Token": "..."}}
+//
+// The body is {step, phase, id, error, vars}. Returned as a closure so the HTTP
+// client (and its timeout/transport) is shared across invocations.
+func webhookHook(client *http.Client) Hook {
+ return func(ctx context.Context, st *State, info HookInfo, params json.RawMessage) error {
+ var p struct {
+ URL string `json:"url"`
+ Headers map[string]string `json:"headers,omitempty"`
+ }
+ if err := json.Unmarshal(params, &p); err != nil {
+ return fmt.Errorf("webhook: bad params: %w", err)
+ }
+ if p.URL == "" {
+ return fmt.Errorf("webhook: missing url")
+ }
+ errStr := ""
+ if info.Err != nil {
+ errStr = info.Err.Error()
+ }
+ body, _ := json.Marshal(map[string]any{
+ "step": info.Step,
+ "phase": info.Phase,
+ "id": st.ID,
+ "error": errStr,
+ "vars": st.Vars,
+ })
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.URL, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ for k, v := range p.Headers {
+ req.Header.Set(k, v)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ return fmt.Errorf("webhook: %s returned %d", p.URL, resp.StatusCode)
+ }
+ return nil
+ }
+}
diff --git a/pkg/order/actions.go b/pkg/order/actions.go
new file mode 100644
index 0000000..151046e
--- /dev/null
+++ b/pkg/order/actions.go
@@ -0,0 +1,231 @@
+package order
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "git.k6n.net/mats/go-cart-actor/pkg/actor"
+ "git.k6n.net/mats/go-cart-actor/pkg/flow"
+ messages "git.k6n.net/mats/go-cart-actor/proto/order"
+ "google.golang.org/protobuf/proto"
+)
+
+//go:embed flows/*.json
+var flowFS embed.FS
+
+// EmbeddedFlow returns a built-in flow definition by name (e.g. "place-and-pay").
+// Deployments can ship their own JSON instead; this is the default.
+func EmbeddedFlow(name string) (*flow.Definition, error) {
+ data, err := flowFS.ReadFile("flows/" + name + ".json")
+ if err != nil {
+ return nil, fmt.Errorf("order: no embedded flow %q: %w", name, err)
+ }
+ return flow.Parse(data)
+}
+
+// Applier is the slice of the grain pool the order flow actions need. The
+// SimpleGrainPool[OrderGrain] satisfies it directly.
+type Applier interface {
+ Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[OrderGrain], error)
+ Get(ctx context.Context, id uint64) (*OrderGrain, error)
+}
+
+func nowMs() int64 { return time.Now().UnixMilli() }
+
+// applyOne applies a single mutation and surfaces the handler error. The grain
+// pool only returns a top-level error for *unregistered* mutations; a handler
+// error (e.g. an illegal state transition) is carried per-mutation in the
+// result, so a flow action must inspect it or a rejected event looks like a
+// success and the flow proceeds wrongly.
+func applyOne(ctx context.Context, app Applier, 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
+}
+
+// PlaceOrderVar is the key under which the caller stores the *messages.PlaceOrder
+// (built from the cart/checkout) in the flow State before running a flow. The
+// flow config controls sequence + hooks; the order data flows in via State.
+const PlaceOrderVar = "placeOrder"
+
+// RegisterFlowActions registers the order lifecycle actions on reg, driving the
+// grain through app and taking payment via provider. Compose with
+// flow.RegisterBuiltinHooks(reg) to get the "log"/"webhook" hooks too.
+func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvider) {
+ reg.Action("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
+ po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder)
+ if !ok || po == nil {
+ return fmt.Errorf("place_order: flow var %q is not a *PlaceOrder", PlaceOrderVar)
+ }
+ if po.PlacedAtMs == 0 {
+ po.PlacedAtMs = nowMs()
+ }
+ if err := applyOne(ctx, app, st.ID, po); err != nil {
+ return err
+ }
+ st.Vars["currency"] = po.GetCurrency()
+ st.Vars["orderReference"] = po.GetOrderReference()
+ return nil
+ })
+
+ reg.Action("authorize_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
+ o, err := app.Get(ctx, st.ID)
+ if err != nil {
+ return err
+ }
+ auth, err := provider.Authorize(ctx, o.OrderReference, o.TotalAmount, o.Currency)
+ if err != nil {
+ return err
+ }
+ if err := applyOne(ctx, app, st.ID, &messages.AuthorizePayment{
+ Provider: auth.Provider,
+ Amount: auth.Amount,
+ Reference: auth.Reference,
+ AtMs: nowMs(),
+ }); err != nil {
+ return err
+ }
+ st.Vars["authRef"] = auth.Reference
+ st.Vars["authProvider"] = auth.Provider
+ st.Vars["authAmount"] = auth.Amount
+ return nil
+ })
+
+ reg.Action("capture_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
+ authRef, _ := st.Vars["authRef"].(string)
+ amount, _ := st.Vars["authAmount"].(int64)
+ if amount == 0 {
+ if o, err := app.Get(ctx, st.ID); err == nil {
+ amount = o.TotalAmount
+ }
+ }
+ capture, err := provider.Capture(ctx, authRef, amount)
+ if err != nil {
+ return err
+ }
+ return applyOne(ctx, app, st.ID, &messages.CapturePayment{
+ Provider: capture.Provider,
+ Amount: capture.Amount,
+ Reference: capture.Reference,
+ AtMs: nowMs(),
+ })
+ })
+
+ // void_payment is the compensation for authorize_payment: void with the
+ // provider and cancel the order if it is still in a cancellable state.
+ reg.Action("void_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
+ if authRef, ok := st.Vars["authRef"].(string); ok && authRef != "" {
+ if err := provider.Void(ctx, authRef); err != nil {
+ return err
+ }
+ }
+ // Best-effort cancel; ignore if no longer cancellable.
+ _, _ = app.Apply(ctx, st.ID, &messages.CancelOrder{Reason: "compensation: payment voided", AtMs: nowMs()})
+ return nil
+ })
+
+ reg.Action("cancel_order", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
+ reason := "cancelled"
+ if len(params) > 0 {
+ var p struct {
+ Reason string `json:"reason"`
+ }
+ if json.Unmarshal(params, &p) == nil && p.Reason != "" {
+ reason = p.Reason
+ }
+ }
+ return applyOne(ctx, app, st.ID, &messages.CancelOrder{Reason: reason, AtMs: nowMs()})
+ })
+
+ reg.Action("issue_refund", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
+ o, err := app.Get(ctx, st.ID)
+ if err != nil {
+ return err
+ }
+ amount := o.CapturedAmount - o.RefundedAmount // default: full remaining
+ if len(params) > 0 {
+ var p struct {
+ Amount int64 `json:"amount"`
+ }
+ if json.Unmarshal(params, &p) == nil && p.Amount > 0 {
+ amount = p.Amount
+ }
+ }
+ captureRef := ""
+ for _, p := range o.Payments {
+ if p.Captured > 0 && p.CaptureRef != "" {
+ captureRef = p.CaptureRef
+ break
+ }
+ }
+ ref, err := provider.Refund(ctx, captureRef, amount)
+ if err != nil {
+ return err
+ }
+ return applyOne(ctx, app, st.ID, &messages.IssueRefund{
+ Provider: ref.Provider,
+ Amount: ref.Amount,
+ Reference: ref.Reference,
+ AtMs: nowMs(),
+ })
+ })
+
+ registerPredicates(reg, app)
+}
+
+// registerPredicates adds the order gating predicates a step can reference via
+// its "when". They read the current order grain, so they reflect the live state
+// at the moment the step is reached (e.g. only fire a receipt hook once the
+// order is captured). Predicates take no params (the engine's When is a bare
+// name), so each is a fixed, composable boolean.
+func registerPredicates(reg *flow.Registry, app Applier) {
+ get := func(ctx context.Context, st *flow.State) (*OrderGrain, error) { return app.Get(ctx, st.ID) }
+
+ reg.Predicate("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
+ o, err := get(ctx, st)
+ if err != nil {
+ return false, err
+ }
+ return o.CustomerEmail != "", nil
+ })
+ reg.Predicate("has_items", func(ctx context.Context, st *flow.State) (bool, error) {
+ o, err := get(ctx, st)
+ if err != nil {
+ return false, err
+ }
+ return len(o.Lines) > 0, nil
+ })
+ reg.Predicate("is_captured", func(ctx context.Context, st *flow.State) (bool, error) {
+ o, err := get(ctx, st)
+ if err != nil {
+ return false, err
+ }
+ return o.Status == StatusCaptured, nil
+ })
+ reg.Predicate("not_captured", func(ctx context.Context, st *flow.State) (bool, error) {
+ o, err := get(ctx, st)
+ if err != nil {
+ return false, err
+ }
+ return o.Status != StatusCaptured, nil
+ })
+ reg.Predicate("has_open_balance", func(ctx context.Context, st *flow.State) (bool, error) {
+ o, err := get(ctx, st)
+ if err != nil {
+ return false, err
+ }
+ return o.CapturedAmount-o.RefundedAmount > 0, nil
+ })
+}
diff --git a/pkg/order/capabilities_test.go b/pkg/order/capabilities_test.go
new file mode 100644
index 0000000..ca33d71
--- /dev/null
+++ b/pkg/order/capabilities_test.go
@@ -0,0 +1,96 @@
+package order
+
+import (
+ "context"
+ "encoding/json"
+ "slices"
+ "strings"
+ "testing"
+
+ "git.k6n.net/mats/go-cart-actor/pkg/flow"
+)
+
+func fullRegistry(t *testing.T, pub Publisher) *flow.Registry {
+ t.Helper()
+ reg := flow.NewRegistry()
+ flow.RegisterBuiltinHooks(reg)
+ RegisterFlowActions(reg, newPool(t), NewMockProvider())
+ RegisterEmitHook(reg, pub)
+ return reg
+}
+
+func TestCapabilitiesIncludesPredicatesAndEmit(t *testing.T) {
+ caps := fullRegistry(t, nil).Capabilities()
+
+ for _, want := range []string{"has_customer_email", "has_items", "is_captured", "not_captured", "has_open_balance"} {
+ if !slices.Contains(caps.Predicates, want) {
+ t.Errorf("predicates missing %q (got %v)", want, caps.Predicates)
+ }
+ }
+ if !slices.Contains(caps.Hooks, "amqp_emit") {
+ t.Errorf("hooks missing amqp_emit (got %v)", caps.Hooks)
+ }
+}
+
+func TestIsCapturedPredicateGatesStep(t *testing.T) {
+ pool := newPool(t)
+ reg := flow.NewRegistry()
+ flow.RegisterBuiltinHooks(reg)
+ RegisterFlowActions(reg, pool, NewMockProvider())
+ eng := flow.NewEngine(reg, nil)
+
+ ran := false
+ reg.Action("mark", func(_ context.Context, _ *flow.State, _ json.RawMessage) error { ran = true; return nil })
+
+ // Capture order 9001 via place-and-pay.
+ def, _ := EmbeddedFlow("place-and-pay")
+ paid := flow.NewState(9001, nil)
+ paid.Vars[PlaceOrderVar] = placeMsg()
+ if _, err := eng.Run(context.Background(), def, paid); err != nil {
+ t.Fatal(err)
+ }
+
+ gated := &flow.Definition{Name: "g", Steps: []flow.Step{{Name: "m", Action: "mark", When: "is_captured"}}}
+ if _, err := eng.Run(context.Background(), gated, flow.NewState(9001, nil)); err != nil {
+ t.Fatal(err)
+ }
+ if !ran {
+ t.Fatal("is_captured should pass for a captured order")
+ }
+
+ // Fresh (unplaced) order → is_captured false → step skipped.
+ ran = false
+ if _, err := eng.Run(context.Background(), gated, flow.NewState(9999, nil)); err != nil {
+ t.Fatal(err)
+ }
+ if ran {
+ t.Fatal("is_captured should skip for a new order")
+ }
+}
+
+type fakePublisher struct{ msgs []string }
+
+func (f *fakePublisher) Publish(_, routingKey string, body []byte) error {
+ f.msgs = append(f.msgs, routingKey+":"+string(body))
+ return nil
+}
+
+func TestAmqpEmitHookPublishes(t *testing.T) {
+ fp := &fakePublisher{}
+ reg := flow.NewRegistry()
+ RegisterEmitHook(reg, fp)
+ reg.Action("noopact", func(_ context.Context, _ *flow.State, _ json.RawMessage) error { return nil })
+ eng := flow.NewEngine(reg, nil)
+
+ def := &flow.Definition{Name: "e", Steps: []flow.Step{{
+ Name: "s",
+ Action: "noopact",
+ Hooks: flow.Hooks{After: []flow.HookRef{{Type: "amqp_emit", Params: json.RawMessage(`{"routingKey":"k"}`)}}},
+ }}}
+ if _, err := eng.Run(context.Background(), def, flow.NewState(7, nil)); err != nil {
+ t.Fatal(err)
+ }
+ if len(fp.msgs) != 1 || !strings.HasPrefix(fp.msgs[0], "k:") {
+ t.Fatalf("expected one message routed to k, got %v", fp.msgs)
+ }
+}
diff --git a/pkg/order/flow_test.go b/pkg/order/flow_test.go
new file mode 100644
index 0000000..27be625
--- /dev/null
+++ b/pkg/order/flow_test.go
@@ -0,0 +1,122 @@
+package order
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "git.k6n.net/mats/go-cart-actor/pkg/actor"
+ "git.k6n.net/mats/go-cart-actor/pkg/flow"
+)
+
+// newPool builds a single-node order grain pool backed by a disk event log in a
+// temp dir — the same machinery production uses, minus clustering.
+func newPool(t *testing.T) *actor.SimpleGrainPool[OrderGrain] {
+ t.Helper()
+ reg := actor.NewMutationRegistry()
+ RegisterMutations(reg)
+ storage := actor.NewDiskStorage[OrderGrain](t.TempDir(), reg)
+ pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[OrderGrain]{
+ Hostname: "test",
+ Spawn: func(_ context.Context, id uint64) (actor.Grain[OrderGrain], error) {
+ return NewOrderGrain(id, time.Now()), nil
+ },
+ SpawnHost: func(string) (actor.Host[OrderGrain], error) { return nil, fmt.Errorf("no remotes") },
+ Destroy: func(actor.Grain[OrderGrain]) error { return nil },
+ TTL: time.Hour,
+ PoolSize: 100,
+
+ MutationRegistry: reg,
+ Storage: storage,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ return pool
+}
+
+func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
+ reg := flow.NewRegistry()
+ flow.RegisterBuiltinHooks(reg)
+ RegisterFlowActions(reg, pool, provider)
+ return reg
+}
+
+func TestPlaceAndPayFlow(t *testing.T) {
+ pool := newPool(t)
+ eng := flow.NewEngine(newFlowRegistry(pool, NewMockProvider()), nil)
+
+ def, err := EmbeddedFlow("place-and-pay")
+ if err != nil {
+ t.Fatalf("load flow: %v", err)
+ }
+ if err := eng.Validate(def); err != nil {
+ t.Fatalf("validate: %v", err)
+ }
+
+ const id = 1001
+ st := flow.NewState(id, nil)
+ st.Vars[PlaceOrderVar] = placeMsg()
+
+ res, err := eng.Run(context.Background(), def, st)
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ if res.Failed {
+ t.Fatalf("flow reported failure: %+v", res.Steps)
+ }
+
+ o, err := pool.Get(context.Background(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Status != StatusCaptured {
+ t.Fatalf("status = %q, want captured", o.Status)
+ }
+ if o.CapturedAmount != 25000 {
+ t.Fatalf("captured = %d, want 25000", o.CapturedAmount)
+ }
+}
+
+// failCapture authorizes fine but fails to capture, exercising the saga
+// compensation path (void_payment + cancel).
+type failCapture struct{ *MockProvider }
+
+func (failCapture) Capture(context.Context, string, int64) (Capture, error) {
+ return Capture{}, fmt.Errorf("processor declined capture")
+}
+
+func TestCaptureFailureCompensates(t *testing.T) {
+ pool := newPool(t)
+ eng := flow.NewEngine(newFlowRegistry(pool, failCapture{NewMockProvider()}), nil)
+
+ def, err := EmbeddedFlow("place-and-pay")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ const id = 1002
+ st := flow.NewState(id, nil)
+ st.Vars[PlaceOrderVar] = placeMsg()
+
+ res, err := eng.Run(context.Background(), def, st)
+ if err == nil {
+ t.Fatal("expected flow to fail on capture")
+ }
+ if !res.Failed {
+ t.Fatal("result should be marked failed")
+ }
+ if len(res.Compensated) != 1 || res.Compensated[0] != "authorize" {
+ t.Fatalf("compensated = %v, want [authorize]", res.Compensated)
+ }
+
+ o, err := pool.Get(context.Background(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Status != StatusCancelled {
+ t.Fatalf("status = %q, want cancelled (compensated)", o.Status)
+ }
+}
diff --git a/pkg/order/flows/place-and-pay.json b/pkg/order/flows/place-and-pay.json
new file mode 100644
index 0000000..f599045
--- /dev/null
+++ b/pkg/order/flows/place-and-pay.json
@@ -0,0 +1,29 @@
+{
+ "name": "place-and-pay",
+ "description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails.",
+ "steps": [
+ {
+ "name": "place",
+ "action": "place_order",
+ "hooks": {
+ "after": [{ "type": "log", "params": { "message": "order placed" } }]
+ }
+ },
+ {
+ "name": "authorize",
+ "action": "authorize_payment",
+ "compensate": { "action": "void_payment" },
+ "hooks": {
+ "after": [{ "type": "log", "params": { "message": "payment authorized" } }],
+ "onError": [{ "type": "log", "params": { "message": "authorization failed" } }]
+ }
+ },
+ {
+ "name": "capture",
+ "action": "capture_payment",
+ "hooks": {
+ "after": [{ "type": "log", "params": { "message": "payment captured" } }]
+ }
+ }
+ ]
+}
diff --git a/pkg/order/hooks.go b/pkg/order/hooks.go
new file mode 100644
index 0000000..a39b435
--- /dev/null
+++ b/pkg/order/hooks.go
@@ -0,0 +1,58 @@
+package order
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "git.k6n.net/mats/go-cart-actor/pkg/flow"
+)
+
+// Publisher is the minimal seam the amqp_emit hook needs. cmd/order supplies a
+// RabbitMQ-backed implementation; tests pass a fake. Keeping it an interface
+// keeps pkg/order free of an AMQP dependency.
+type Publisher interface {
+ Publish(exchange, routingKey string, body []byte) error
+}
+
+// RegisterEmitHook registers the "amqp_emit" flow hook, which publishes a flow
+// event to a message broker — the bridge from a saga step to other services
+// (loyalty, ERP sync, notifications) without coupling the flow to them. It is
+// the commerce analogue of the CMS event listeners. Params (optional):
+//
+// {"exchange": "", "routingKey": "order-events"}
+//
+// The body is {step, phase, id, error, vars}. With a nil publisher the hook is
+// still registered (so the editor lists it) but errors at run time — and since
+// hook errors never abort a flow, that failure is logged, not fatal.
+func RegisterEmitHook(reg *flow.Registry, pub Publisher) {
+ reg.Hook("amqp_emit", func(_ context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
+ if pub == nil {
+ return fmt.Errorf("amqp_emit: no publisher configured")
+ }
+ var p struct {
+ Exchange string `json:"exchange"`
+ RoutingKey string `json:"routingKey"`
+ }
+ if len(params) > 0 {
+ if err := json.Unmarshal(params, &p); err != nil {
+ return fmt.Errorf("amqp_emit: bad params: %w", err)
+ }
+ }
+ if p.RoutingKey == "" {
+ p.RoutingKey = "order-events"
+ }
+ errStr := ""
+ if info.Err != nil {
+ errStr = info.Err.Error()
+ }
+ body, _ := json.Marshal(map[string]any{
+ "step": info.Step,
+ "phase": info.Phase,
+ "id": st.ID,
+ "error": errStr,
+ "vars": st.Vars,
+ })
+ return pub.Publish(p.Exchange, p.RoutingKey, body)
+ })
+}
diff --git a/pkg/order/id.go b/pkg/order/id.go
new file mode 100644
index 0000000..1b3e162
--- /dev/null
+++ b/pkg/order/id.go
@@ -0,0 +1,71 @@
+package order
+
+import (
+ "crypto/rand"
+ "fmt"
+)
+
+// OrderId is a 64-bit order identifier with a compact base62 string form, the
+// same scheme the cart uses (cart.CartId) so ids are consistent across the
+// commerce services. The grain is keyed by the raw uint64.
+type OrderId uint64
+
+const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+
+var base62Rev [256]byte
+
+func init() {
+ for i := range base62Rev {
+ base62Rev[i] = 0xFF
+ }
+ for i := 0; i < len(base62Alphabet); i++ {
+ base62Rev[base62Alphabet[i]] = byte(i)
+ }
+}
+
+// String returns the canonical base62 encoding.
+func (id OrderId) String() string { return encodeBase62(uint64(id)) }
+
+// NewOrderId generates a cryptographically random non-zero id.
+func NewOrderId() (OrderId, error) {
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return 0, fmt.Errorf("NewOrderId: %w", err)
+ }
+ u := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
+ uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
+ if u == 0 {
+ return NewOrderId()
+ }
+ return OrderId(u), nil
+}
+
+// ParseOrderId parses a base62 string into an OrderId.
+func ParseOrderId(s string) (OrderId, bool) {
+ if len(s) == 0 || len(s) > 11 {
+ return 0, false
+ }
+ var v uint64
+ for i := 0; i < len(s); i++ {
+ d := base62Rev[s[i]]
+ if d == 0xFF {
+ return 0, false
+ }
+ v = v*62 + uint64(d)
+ }
+ return OrderId(v), true
+}
+
+func encodeBase62(u uint64) string {
+ if u == 0 {
+ return "0"
+ }
+ var buf [11]byte
+ i := len(buf)
+ for u > 0 {
+ i--
+ buf[i] = base62Alphabet[u%62]
+ u /= 62
+ }
+ return string(buf[i:])
+}
diff --git a/pkg/order/mutations.go b/pkg/order/mutations.go
new file mode 100644
index 0000000..3509b4c
--- /dev/null
+++ b/pkg/order/mutations.go
@@ -0,0 +1,249 @@
+package order
+
+import (
+ "fmt"
+
+ "git.k6n.net/mats/go-cart-actor/pkg/actor"
+ messages "git.k6n.net/mats/go-cart-actor/proto/order"
+)
+
+// This file holds the mutation handlers. Each handler is the *only* place a
+// given transition is allowed; it validates the current status against the
+// state machine (canTransition) before mutating, so an illegal event (e.g.
+// capturing an unplaced order) returns an error and is never recorded.
+//
+// Handlers must be deterministic: replaying the event log through them must
+// reproduce the same state. Timestamps therefore come from the event payload
+// (msToString), never from time.Now().
+
+// require returns an error unless from -> to is a legal transition.
+func require(from, to Status, op string) error {
+ if !canTransition(from, to) {
+ return fmt.Errorf("order: cannot %s in status %q", op, from)
+ }
+ return nil
+}
+
+// HandlePlaceOrder creates the order. Legal only when the order is new.
+func HandlePlaceOrder(o *OrderGrain, m *messages.PlaceOrder) error {
+ if o.Status != StatusNew {
+ return fmt.Errorf("order: already placed (status %q)", o.Status)
+ }
+ if len(m.GetLines()) == 0 {
+ return fmt.Errorf("order: cannot place an order with no lines")
+ }
+ o.OrderReference = m.GetOrderReference()
+ o.CartId = m.GetCartId()
+ o.Currency = m.GetCurrency()
+ o.Locale = m.GetLocale()
+ o.Country = m.GetCountry()
+ o.TotalAmount = m.GetTotalAmount()
+ o.TotalTax = m.GetTotalTax()
+ o.CustomerEmail = m.GetCustomerEmail()
+ o.CustomerName = m.GetCustomerName()
+ if b := m.GetBillingAddress(); len(b) > 0 {
+ o.BillingAddress = append([]byte(nil), b...)
+ }
+ if s := m.GetShippingAddress(); len(s) > 0 {
+ o.ShippingAddress = append([]byte(nil), s...)
+ }
+ o.Lines = o.Lines[:0]
+ for _, l := range m.GetLines() {
+ o.Lines = append(o.Lines, Line{
+ Reference: l.GetReference(),
+ Sku: l.GetSku(),
+ Name: l.GetName(),
+ Quantity: int(l.GetQuantity()),
+ UnitPrice: l.GetUnitPrice(),
+ TaxRate: int(l.GetTaxRate()),
+ TotalAmount: l.GetTotalAmount(),
+ TotalTax: l.GetTotalTax(),
+ })
+ }
+ o.Status = StatusPending
+ o.PlacedAt = msToString(m.GetPlacedAtMs())
+ o.touch(o.PlacedAt)
+ return nil
+}
+
+// HandleAuthorizePayment records an authorization: pending -> authorized.
+func HandleAuthorizePayment(o *OrderGrain, m *messages.AuthorizePayment) error {
+ if err := require(o.Status, StatusAuthorized, "authorize payment"); err != nil {
+ return err
+ }
+ at := msToString(m.GetAtMs())
+ o.Payments = append(o.Payments, &Payment{
+ Provider: m.GetProvider(),
+ Authorized: m.GetAmount(),
+ AuthRef: m.GetReference(),
+ AuthorizedAt: at,
+ })
+ o.Status = StatusAuthorized
+ o.touch(at)
+ return nil
+}
+
+// HandleCapturePayment records a capture: authorized -> captured.
+func HandleCapturePayment(o *OrderGrain, m *messages.CapturePayment) error {
+ if err := require(o.Status, StatusCaptured, "capture payment"); err != nil {
+ return err
+ }
+ p := o.matchingPayment(m.GetReference(), m.GetProvider())
+ if p == nil {
+ return fmt.Errorf("order: capture has no matching authorized payment")
+ }
+ at := msToString(m.GetAtMs())
+ p.Captured += m.GetAmount()
+ p.CaptureRef = m.GetReference()
+ p.CapturedAt = at
+ o.CapturedAmount += m.GetAmount()
+ o.Status = StatusCaptured
+ o.touch(at)
+ return nil
+}
+
+// matchingPayment finds the authorized payment this capture belongs to. A mock
+// capture reference is "mock-capture-"; we match on provider and fall
+// back to the first authorized-but-uncaptured payment for that provider.
+func (o *OrderGrain) matchingPayment(_ string, provider string) *Payment {
+ for _, p := range o.Payments {
+ if p.Provider == provider && p.Captured == 0 {
+ return p
+ }
+ }
+ if len(o.Payments) > 0 {
+ return o.Payments[len(o.Payments)-1]
+ }
+ return nil
+}
+
+// HandleCreateFulfillment ships lines: captured/partially_fulfilled ->
+// partially_fulfilled or fulfilled (once every line's quantity has shipped).
+func HandleCreateFulfillment(o *OrderGrain, m *messages.CreateFulfillment) error {
+ if o.Status != StatusCaptured && o.Status != StatusPartiallyFulfilled {
+ return fmt.Errorf("order: cannot fulfill in status %q", o.Status)
+ }
+ f := Fulfillment{
+ ID: m.GetId(),
+ Carrier: m.GetCarrier(),
+ TrackingNumber: m.GetTrackingNumber(),
+ TrackingURI: m.GetTrackingUri(),
+ CreatedAt: msToString(m.GetAtMs()),
+ }
+ for _, fl := range m.GetLines() {
+ line := o.findLine(fl.GetReference())
+ if line == nil {
+ return fmt.Errorf("order: fulfillment references unknown line %q", fl.GetReference())
+ }
+ qty := int(fl.GetQuantity())
+ if line.Fulfilled+qty > line.Quantity {
+ return fmt.Errorf("order: fulfillment for line %q exceeds ordered quantity", fl.GetReference())
+ }
+ line.Fulfilled += qty
+ f.Lines = append(f.Lines, FulfillmentEntry{Reference: fl.GetReference(), Quantity: qty})
+ }
+ o.Fulfillments = append(o.Fulfillments, f)
+ target := StatusPartiallyFulfilled
+ if o.allLinesFulfilled() {
+ target = StatusFulfilled
+ }
+ if err := require(o.Status, target, "fulfill"); err != nil {
+ return err
+ }
+ o.Status = target
+ o.touch(f.CreatedAt)
+ return nil
+}
+
+// HandleCompleteOrder closes a fully-fulfilled order: fulfilled -> completed.
+func HandleCompleteOrder(o *OrderGrain, m *messages.CompleteOrder) error {
+ if err := require(o.Status, StatusCompleted, "complete"); err != nil {
+ return err
+ }
+ o.Status = StatusCompleted
+ o.touch(msToString(m.GetAtMs()))
+ return nil
+}
+
+// HandleCancelOrder cancels before capture: pending/authorized -> cancelled.
+func HandleCancelOrder(o *OrderGrain, m *messages.CancelOrder) error {
+ if err := require(o.Status, StatusCancelled, "cancel"); err != nil {
+ return err
+ }
+ o.Status = StatusCancelled
+ o.touch(msToString(m.GetAtMs()))
+ return nil
+}
+
+// HandleRequestReturn opens an RMA against fulfilled lines. Allowed once an
+// order is fulfilled or completed; it does not change the order status.
+func HandleRequestReturn(o *OrderGrain, m *messages.RequestReturn) error {
+ if o.Status != StatusFulfilled && o.Status != StatusCompleted && o.Status != StatusPartiallyFulfilled {
+ return fmt.Errorf("order: cannot request a return in status %q", o.Status)
+ }
+ r := Return{
+ ID: m.GetId(),
+ Reason: m.GetReason(),
+ RequestedAt: msToString(m.GetAtMs()),
+ }
+ for _, l := range m.GetLines() {
+ if o.findLine(l.GetReference()) == nil {
+ return fmt.Errorf("order: return references unknown line %q", l.GetReference())
+ }
+ r.Lines = append(r.Lines, FulfillmentEntry{Reference: l.GetReference(), Quantity: int(l.GetQuantity())})
+ }
+ o.Returns = append(o.Returns, r)
+ o.touch(r.RequestedAt)
+ return nil
+}
+
+// HandleIssueRefund records a refund. Allowed from any captured state; once the
+// refunded total reaches the captured total the order becomes refunded.
+func HandleIssueRefund(o *OrderGrain, m *messages.IssueRefund) error {
+ switch o.Status {
+ case StatusCaptured, StatusPartiallyFulfilled, StatusFulfilled, StatusCompleted:
+ default:
+ return fmt.Errorf("order: cannot refund in status %q", o.Status)
+ }
+ if m.GetAmount() <= 0 {
+ return fmt.Errorf("order: refund amount must be positive")
+ }
+ if o.RefundedAmount+m.GetAmount() > o.CapturedAmount {
+ return fmt.Errorf("order: refund exceeds captured amount")
+ }
+ at := msToString(m.GetAtMs())
+ o.Refunds = append(o.Refunds, Refund{
+ Provider: m.GetProvider(),
+ Amount: m.GetAmount(),
+ Reference: m.GetReference(),
+ ReturnID: m.GetReturnId(),
+ IssuedAt: at,
+ })
+ o.RefundedAmount += m.GetAmount()
+ for _, p := range o.Payments {
+ if p.Provider == m.GetProvider() {
+ p.Refunded += m.GetAmount()
+ break
+ }
+ }
+ if o.RefundedAmount >= o.CapturedAmount {
+ o.Status = StatusRefunded
+ }
+ o.touch(at)
+ return nil
+}
+
+// RegisterMutations registers every order mutation handler with the registry so
+// the grain pool can apply and replay them.
+func RegisterMutations(reg actor.MutationRegistry) {
+ reg.RegisterMutations(
+ actor.NewMutation(HandlePlaceOrder),
+ actor.NewMutation(HandleAuthorizePayment),
+ actor.NewMutation(HandleCapturePayment),
+ actor.NewMutation(HandleCreateFulfillment),
+ actor.NewMutation(HandleCompleteOrder),
+ actor.NewMutation(HandleCancelOrder),
+ actor.NewMutation(HandleRequestReturn),
+ actor.NewMutation(HandleIssueRefund),
+ )
+}
diff --git a/pkg/order/order-grain.go b/pkg/order/order-grain.go
new file mode 100644
index 0000000..031b784
--- /dev/null
+++ b/pkg/order/order-grain.go
@@ -0,0 +1,213 @@
+// Package order models an order as an actor grain (same framework as the cart
+// and checkout grains). Mutations are the proto messages in proto/order; the
+// grain's event log is the source of truth and the OrderGrain below is the
+// projection rebuilt by replaying them. The order state machine (transitions
+// map + canTransition) is the guardrail enforced inside the mutation handlers.
+package order
+
+import (
+ "encoding/json"
+ "sync"
+ "time"
+)
+
+// Status is the order lifecycle state.
+type Status string
+
+const (
+ StatusNew Status = "" // not yet placed
+ StatusPending Status = "pending" // placed, awaiting payment
+ StatusAuthorized Status = "authorized" // payment authorized
+ StatusCaptured Status = "captured" // payment captured (paid)
+ StatusPartiallyFulfilled Status = "partially_fulfilled" // some lines shipped
+ StatusFulfilled Status = "fulfilled" // all lines shipped
+ StatusCompleted Status = "completed" // closed out
+ StatusCancelled Status = "cancelled" // terminal
+ StatusRefunded Status = "refunded" // terminal
+)
+
+// transitions is the legal status graph. A target absent from a source's list
+// is rejected by the handler that would have caused it. Returns/refunds that do
+// not move status are validated separately in their handlers.
+var transitions = map[Status][]Status{
+ StatusNew: {StatusPending},
+ StatusPending: {StatusAuthorized, StatusCancelled},
+ StatusAuthorized: {StatusCaptured, StatusCancelled},
+ StatusCaptured: {StatusPartiallyFulfilled, StatusFulfilled, StatusRefunded},
+ StatusPartiallyFulfilled: {StatusPartiallyFulfilled, StatusFulfilled, StatusRefunded},
+ StatusFulfilled: {StatusCompleted, StatusRefunded},
+ StatusCompleted: {StatusRefunded},
+ StatusCancelled: {},
+ StatusRefunded: {},
+}
+
+// canTransition reports whether from -> to is a legal status change.
+func canTransition(from, to Status) bool {
+ for _, s := range transitions[from] {
+ if s == to {
+ return true
+ }
+ }
+ return false
+}
+
+// Line is an ordered line item (projected from PlaceOrder).
+type Line struct {
+ Reference string `json:"reference"`
+ Sku string `json:"sku"`
+ Name string `json:"name"`
+ Quantity int `json:"quantity"`
+ UnitPrice int64 `json:"unitPrice"`
+ TaxRate int `json:"taxRate"`
+ TotalAmount int64 `json:"totalAmount"`
+ TotalTax int64 `json:"totalTax"`
+ // Fulfilled tracks how many units of this line have shipped.
+ Fulfilled int `json:"fulfilled"`
+}
+
+// Payment records one authorization/capture against a provider.
+type Payment struct {
+ Provider string `json:"provider"`
+ Authorized int64 `json:"authorized"`
+ Captured int64 `json:"captured"`
+ Refunded int64 `json:"refunded"`
+ AuthRef string `json:"authRef,omitempty"`
+ CaptureRef string `json:"captureRef,omitempty"`
+ AuthorizedAt string `json:"authorizedAt,omitempty"`
+ CapturedAt string `json:"capturedAt,omitempty"`
+}
+
+// Fulfillment records a shipment of some lines.
+type Fulfillment struct {
+ ID string `json:"id"`
+ Carrier string `json:"carrier,omitempty"`
+ TrackingNumber string `json:"trackingNumber,omitempty"`
+ TrackingURI string `json:"trackingUri,omitempty"`
+ Lines []FulfillmentEntry `json:"lines,omitempty"`
+ CreatedAt string `json:"createdAt,omitempty"`
+}
+
+// FulfillmentEntry is one shipped line within a fulfillment.
+type FulfillmentEntry struct {
+ Reference string `json:"reference"`
+ Quantity int `json:"quantity"`
+}
+
+// Return records an RMA request.
+type Return struct {
+ ID string `json:"id"`
+ Reason string `json:"reason,omitempty"`
+ Lines []FulfillmentEntry `json:"lines,omitempty"`
+ RequestedAt string `json:"requestedAt,omitempty"`
+}
+
+// Refund records a refund issued against the order.
+type Refund struct {
+ Provider string `json:"provider"`
+ Amount int64 `json:"amount"`
+ Reference string `json:"reference,omitempty"`
+ ReturnID string `json:"returnId,omitempty"`
+ IssuedAt string `json:"issuedAt,omitempty"`
+}
+
+// OrderGrain is the projected current state of an order. It implements
+// actor.Grain[OrderGrain].
+type OrderGrain struct {
+ mu sync.RWMutex
+ lastAccess time.Time
+ lastChange time.Time
+
+ Id uint64 `json:"id"`
+ OrderReference string `json:"orderReference,omitempty"`
+ CartId string `json:"cartId,omitempty"`
+ Status Status `json:"status"`
+ Currency string `json:"currency,omitempty"`
+ Locale string `json:"locale,omitempty"`
+ Country string `json:"country,omitempty"`
+
+ TotalAmount int64 `json:"totalAmount"`
+ TotalTax int64 `json:"totalTax"`
+ Lines []Line `json:"lines,omitempty"`
+
+ CustomerEmail string `json:"customerEmail,omitempty"`
+ CustomerName string `json:"customerName,omitempty"`
+ BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
+ ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
+
+ Payments []*Payment `json:"payments,omitempty"`
+ Fulfillments []Fulfillment `json:"fulfillments,omitempty"`
+ Returns []Return `json:"returns,omitempty"`
+ Refunds []Refund `json:"refunds,omitempty"`
+
+ CapturedAmount int64 `json:"capturedAmount"`
+ RefundedAmount int64 `json:"refundedAmount"`
+
+ PlacedAt string `json:"placedAt,omitempty"`
+ UpdatedAt string `json:"updatedAt,omitempty"`
+}
+
+// NewOrderGrain returns an empty (not-yet-placed) order grain for id.
+func NewOrderGrain(id uint64, ts time.Time) *OrderGrain {
+ return &OrderGrain{
+ Id: id,
+ Status: StatusNew,
+ lastAccess: ts,
+ lastChange: ts,
+ }
+}
+
+// --- actor.Grain[OrderGrain] ---
+
+func (o *OrderGrain) GetId() uint64 { return o.Id }
+
+func (o *OrderGrain) GetLastAccess() time.Time { return o.lastAccess }
+
+func (o *OrderGrain) GetLastChange() time.Time { return o.lastChange }
+
+func (o *OrderGrain) GetCurrentState() (*OrderGrain, error) {
+ o.lastAccess = time.Now()
+ return o, nil
+}
+
+func (o *OrderGrain) GetState() ([]byte, error) { return json.Marshal(o) }
+
+// touch records a successful mutation, advancing UpdatedAt and lastChange.
+func (o *OrderGrain) touch(at string) {
+ now := time.Now()
+ o.lastChange = now
+ if at != "" {
+ o.UpdatedAt = at
+ }
+}
+
+// findLine returns the line with the given reference, or nil.
+func (o *OrderGrain) findLine(ref string) *Line {
+ for i := range o.Lines {
+ if o.Lines[i].Reference == ref {
+ return &o.Lines[i]
+ }
+ }
+ return nil
+}
+
+// allLinesFulfilled reports whether every line's full quantity has shipped.
+func (o *OrderGrain) allLinesFulfilled() bool {
+ if len(o.Lines) == 0 {
+ return false
+ }
+ for _, l := range o.Lines {
+ if l.Fulfilled < l.Quantity {
+ return false
+ }
+ }
+ return true
+}
+
+// msToString renders a caller-supplied unix-millis timestamp as RFC3339, or ""
+// when zero (so replay stays deterministic — the value comes from the event).
+func msToString(ms int64) string {
+ if ms == 0 {
+ return ""
+ }
+ return time.UnixMilli(ms).UTC().Format(time.RFC3339)
+}
diff --git a/pkg/order/order_test.go b/pkg/order/order_test.go
new file mode 100644
index 0000000..c619119
--- /dev/null
+++ b/pkg/order/order_test.go
@@ -0,0 +1,169 @@
+package order
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "git.k6n.net/mats/go-cart-actor/pkg/actor"
+ messages "git.k6n.net/mats/go-cart-actor/proto/order"
+ "google.golang.org/protobuf/proto"
+)
+
+// apply runs one mutation through the registry and returns the handler error
+// (the registry surfaces per-mutation handler errors in the result, returning a
+// top-level error only for unregistered messages).
+func apply(t *testing.T, reg actor.MutationRegistry, g *OrderGrain, msg proto.Message) error {
+ t.Helper()
+ results, err := reg.Apply(context.Background(), g, msg)
+ if err != nil {
+ return err
+ }
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(results))
+ }
+ return results[0].Error
+}
+
+func newRegistry() actor.MutationRegistry {
+ reg := actor.NewMutationRegistry()
+ RegisterMutations(reg)
+ return reg
+}
+
+func placeMsg() *messages.PlaceOrder {
+ return &messages.PlaceOrder{
+ OrderReference: "ref-1",
+ CartId: "cart-1",
+ Currency: "SEK",
+ Country: "se",
+ TotalAmount: 25000,
+ TotalTax: 5000,
+ PlacedAtMs: time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC).UnixMilli(),
+ Lines: []*messages.OrderLine{
+ {Reference: "l1", Sku: "ABC", Name: "Widget", Quantity: 2, UnitPrice: 10000, TaxRate: 25, TotalAmount: 20000},
+ {Reference: "l2", Sku: "DEF", Name: "Gizmo", Quantity: 1, UnitPrice: 5000, TaxRate: 25, TotalAmount: 5000},
+ },
+ }
+}
+
+func TestHappyPathLifecycle(t *testing.T) {
+ reg := newRegistry()
+ g := NewOrderGrain(1, time.Now())
+
+ steps := []struct {
+ name string
+ msg proto.Message
+ want Status
+ }{
+ {"place", placeMsg(), StatusPending},
+ {"authorize", &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "mock-auth-ref-1"}, StatusAuthorized},
+ {"capture", &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "mock-capture-1"}, StatusCaptured},
+ {"ship l1", &messages.CreateFulfillment{Id: "f1", Lines: []*messages.FulfillmentLine{{Reference: "l1", Quantity: 2}}}, StatusPartiallyFulfilled},
+ {"ship l2", &messages.CreateFulfillment{Id: "f2", Lines: []*messages.FulfillmentLine{{Reference: "l2", Quantity: 1}}}, StatusFulfilled},
+ {"complete", &messages.CompleteOrder{}, StatusCompleted},
+ }
+ for _, s := range steps {
+ if err := apply(t, reg, g, s.msg); err != nil {
+ t.Fatalf("%s: unexpected error: %v", s.name, err)
+ }
+ if g.Status != s.want {
+ t.Fatalf("%s: status = %q, want %q", s.name, g.Status, s.want)
+ }
+ }
+ if g.CapturedAmount != 25000 {
+ t.Fatalf("captured = %d, want 25000", g.CapturedAmount)
+ }
+}
+
+func TestIllegalTransitionsRejected(t *testing.T) {
+ reg := newRegistry()
+
+ // Capture before placing must fail and leave the order untouched.
+ g := NewOrderGrain(2, time.Now())
+ if err := apply(t, reg, g, &messages.CapturePayment{Provider: "mock", Amount: 100}); err == nil {
+ t.Fatal("capture on a new order should fail")
+ }
+ if g.Status != StatusNew {
+ t.Fatalf("status = %q, want new", g.Status)
+ }
+
+ // After capture, cancel is illegal (must refund instead).
+ g = NewOrderGrain(3, time.Now())
+ mustApply(t, reg, g, placeMsg())
+ mustApply(t, reg, g, &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "a"})
+ mustApply(t, reg, g, &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "c"})
+ if err := apply(t, reg, g, &messages.CancelOrder{Reason: "changed mind"}); err == nil {
+ t.Fatal("cancel after capture should fail")
+ }
+ if g.Status != StatusCaptured {
+ t.Fatalf("status = %q, want captured", g.Status)
+ }
+}
+
+func TestRefundClosesOrder(t *testing.T) {
+ reg := newRegistry()
+ g := NewOrderGrain(4, time.Now())
+ mustApply(t, reg, g, placeMsg())
+ mustApply(t, reg, g, &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "a"})
+ mustApply(t, reg, g, &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "c"})
+ mustApply(t, reg, g, &messages.IssueRefund{Provider: "mock", Amount: 25000, Reference: "r"})
+ if g.Status != StatusRefunded {
+ t.Fatalf("status = %q, want refunded", g.Status)
+ }
+ if g.RefundedAmount != 25000 {
+ t.Fatalf("refunded = %d, want 25000", g.RefundedAmount)
+ }
+ // Over-refunding must be rejected.
+ if err := apply(t, reg, g, &messages.IssueRefund{Provider: "mock", Amount: 1, Reference: "r2"}); err == nil {
+ t.Fatal("refund beyond captured should fail")
+ }
+}
+
+// TestEventSourcedReplay proves the core B1.2 property: the order log is the
+// source of truth and replaying it through the registry reproduces the exact
+// projected state.
+func TestEventSourcedReplay(t *testing.T) {
+ reg := newRegistry()
+ storage := actor.NewDiskStorage[OrderGrain](t.TempDir(), reg)
+
+ const id = 42
+ g := NewOrderGrain(id, time.Now())
+ events := []proto.Message{
+ placeMsg(),
+ &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "a", AtMs: 1},
+ &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "c", AtMs: 2},
+ &messages.CreateFulfillment{Id: "f1", Lines: []*messages.FulfillmentLine{{Reference: "l1", Quantity: 2}}, AtMs: 3},
+ }
+ for _, e := range events {
+ mustApply(t, reg, g, e)
+ if err := storage.AppendMutations(id, e); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ }
+
+ // Replay into a brand-new grain and compare the projection.
+ replayed := NewOrderGrain(id, time.Now())
+ if err := storage.LoadEvents(context.Background(), id, replayed); err != nil {
+ t.Fatalf("replay: %v", err)
+ }
+ if replayed.Status != g.Status {
+ t.Fatalf("replayed status = %q, want %q", replayed.Status, g.Status)
+ }
+ if replayed.CapturedAmount != g.CapturedAmount {
+ t.Fatalf("replayed captured = %d, want %d", replayed.CapturedAmount, g.CapturedAmount)
+ }
+ if len(replayed.Fulfillments) != len(g.Fulfillments) {
+ t.Fatalf("replayed fulfillments = %d, want %d", len(replayed.Fulfillments), len(g.Fulfillments))
+ }
+ if got := replayed.Lines[0].Fulfilled; got != 2 {
+ t.Fatalf("replayed line l1 fulfilled = %d, want 2", got)
+ }
+}
+
+func mustApply(t *testing.T, reg actor.MutationRegistry, g *OrderGrain, msg proto.Message) {
+ t.Helper()
+ if err := apply(t, reg, g, msg); err != nil {
+ t.Fatalf("apply %T: %v", msg, err)
+ }
+}
diff --git a/pkg/order/payment.go b/pkg/order/payment.go
new file mode 100644
index 0000000..ccc3b20
--- /dev/null
+++ b/pkg/order/payment.go
@@ -0,0 +1,69 @@
+package order
+
+import (
+ "context"
+ "fmt"
+)
+
+// Authorization is the result of authorizing a payment with a provider.
+type Authorization struct {
+ Provider string
+ Reference string
+ Amount int64
+}
+
+// Capture is the result of capturing an authorization.
+type Capture struct {
+ Provider string
+ Reference string
+ Amount int64
+}
+
+// RefundResult is the result of refunding a captured payment.
+type RefundResult struct {
+ Provider string
+ Reference string
+ Amount int64
+}
+
+// PaymentProvider abstracts a payment processor. It mirrors the ShippingProvider
+// seam in go-shipping: one interface, interchangeable implementations. The order
+// flow actions (pkg/order/actions.go) call this; the grain only records facts.
+type PaymentProvider interface {
+ Name() string
+ Authorize(ctx context.Context, orderRef string, amount int64, currency string) (Authorization, error)
+ Capture(ctx context.Context, authRef string, amount int64) (Capture, error)
+ Refund(ctx context.Context, captureRef string, amount int64) (RefundResult, error)
+ Void(ctx context.Context, authRef string) error
+}
+
+// MockProvider is a deterministic, always-succeeding payment provider. It lets
+// an order be placed and paid end-to-end with no external dependency — the
+// simplest path to a captured order for local dev, demos and tests.
+type MockProvider struct{}
+
+func NewMockProvider() *MockProvider { return &MockProvider{} }
+
+var _ PaymentProvider = (*MockProvider)(nil)
+
+func (m *MockProvider) Name() string { return "mock" }
+
+func (m *MockProvider) Authorize(_ context.Context, orderRef string, amount int64, _ string) (Authorization, error) {
+ return Authorization{Provider: m.Name(), Reference: "mock-auth-" + orderRef, Amount: amount}, nil
+}
+
+func (m *MockProvider) Capture(_ context.Context, authRef string, amount int64) (Capture, error) {
+ if authRef == "" {
+ return Capture{}, fmt.Errorf("mock: capture requires an authorization reference")
+ }
+ return Capture{Provider: m.Name(), Reference: "mock-capture-" + authRef, Amount: amount}, nil
+}
+
+func (m *MockProvider) Refund(_ context.Context, captureRef string, amount int64) (RefundResult, error) {
+ if captureRef == "" {
+ return RefundResult{}, fmt.Errorf("mock: refund requires a capture reference")
+ }
+ return RefundResult{Provider: m.Name(), Reference: "mock-refund-" + captureRef, Amount: amount}, nil
+}
+
+func (m *MockProvider) Void(_ context.Context, _ string) error { return nil }
diff --git a/pkg/order/stripe.go b/pkg/order/stripe.go
new file mode 100644
index 0000000..c5fcfb1
--- /dev/null
+++ b/pkg/order/stripe.go
@@ -0,0 +1,163 @@
+package order
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// StripeProvider implements PaymentProvider against the Stripe REST API using
+// PaymentIntents with manual capture (authorize now, capture on fulfillment),
+// mirroring the order state machine. It speaks the form-encoded REST API
+// directly — no SDK dependency — so it stays in step with the dependency-light
+// style of the rest of the module.
+//
+// Flow mapping:
+// - Authorize -> POST /v1/payment_intents (capture_method=manual, confirm=true)
+// - Capture -> POST /v1/payment_intents/{id}/capture
+// - Refund -> POST /v1/refunds (payment_intent={id})
+// - Void -> POST /v1/payment_intents/{id}/cancel
+//
+// The authorization reference is the PaymentIntent id (pi_...), which is what
+// Capture/Refund/Void operate on.
+type StripeProvider struct {
+ secretKey string
+ baseURL string // defaults to https://api.stripe.com
+ // PaymentMethod is the payment method id/token to confirm with. For a real
+ // integration this comes from the client (Stripe.js); for server-side tests
+ // and headless flows it can be a test token like "pm_card_visa".
+ PaymentMethod string
+ client *http.Client
+}
+
+var _ PaymentProvider = (*StripeProvider)(nil)
+
+// NewStripeProvider returns a Stripe-backed provider. baseURL may be empty to
+// use the live API; tests pass a fake server URL.
+func NewStripeProvider(secretKey, baseURL, paymentMethod string) *StripeProvider {
+ if baseURL == "" {
+ baseURL = "https://api.stripe.com"
+ }
+ if paymentMethod == "" {
+ paymentMethod = "pm_card_visa" // Stripe test token; override for prod
+ }
+ return &StripeProvider{
+ secretKey: secretKey,
+ baseURL: strings.TrimSuffix(baseURL, "/"),
+ PaymentMethod: paymentMethod,
+ client: &http.Client{Timeout: 20 * time.Second},
+ }
+}
+
+func (s *StripeProvider) Name() string { return "stripe" }
+
+// post sends a form-encoded request and decodes the JSON response, returning an
+// error for any non-2xx (surfacing Stripe's error message).
+func (s *StripeProvider) post(ctx context.Context, path string, form url.Values) (map[string]any, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+path, strings.NewReader(form.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.SetBasicAuth(s.secretKey, "")
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var body map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("stripe: decode %s: %w", path, err)
+ }
+ if resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("stripe: %s -> %d: %s", path, resp.StatusCode, stripeErr(body))
+ }
+ return body, nil
+}
+
+func stripeErr(body map[string]any) string {
+ if e, ok := body["error"].(map[string]any); ok {
+ if m, ok := e["message"].(string); ok {
+ return m
+ }
+ }
+ return "unknown error"
+}
+
+func strField(body map[string]any, key string) string {
+ if v, ok := body[key].(string); ok {
+ return v
+ }
+ return ""
+}
+
+func (s *StripeProvider) Authorize(ctx context.Context, orderRef string, amount int64, currency string) (Authorization, error) {
+ form := url.Values{}
+ form.Set("amount", strconv.FormatInt(amount, 10))
+ form.Set("currency", strings.ToLower(currency))
+ form.Set("capture_method", "manual")
+ form.Set("confirm", "true")
+ form.Set("payment_method", s.PaymentMethod)
+ form.Set("description", "order "+orderRef)
+ // Idempotency on the order reference avoids a duplicate intent on retry.
+ form.Set("metadata[order_reference]", orderRef)
+
+ body, err := s.post(ctx, "/v1/payment_intents", form)
+ if err != nil {
+ return Authorization{}, err
+ }
+ id := strField(body, "id")
+ if id == "" {
+ return Authorization{}, fmt.Errorf("stripe: authorize returned no payment intent id")
+ }
+ if status := strField(body, "status"); status != "requires_capture" && status != "succeeded" {
+ return Authorization{}, fmt.Errorf("stripe: authorize status %q", status)
+ }
+ return Authorization{Provider: s.Name(), Reference: id, Amount: amount}, nil
+}
+
+func (s *StripeProvider) Capture(ctx context.Context, authRef string, amount int64) (Capture, error) {
+ if authRef == "" {
+ return Capture{}, fmt.Errorf("stripe: capture requires a payment intent id")
+ }
+ form := url.Values{}
+ if amount > 0 {
+ form.Set("amount_to_capture", strconv.FormatInt(amount, 10))
+ }
+ body, err := s.post(ctx, "/v1/payment_intents/"+url.PathEscape(authRef)+"/capture", form)
+ if err != nil {
+ return Capture{}, err
+ }
+ return Capture{Provider: s.Name(), Reference: strField(body, "id"), Amount: amount}, nil
+}
+
+func (s *StripeProvider) Refund(ctx context.Context, captureRef string, amount int64) (RefundResult, error) {
+ if captureRef == "" {
+ return RefundResult{}, fmt.Errorf("stripe: refund requires a payment intent id")
+ }
+ form := url.Values{}
+ form.Set("payment_intent", captureRef)
+ if amount > 0 {
+ form.Set("amount", strconv.FormatInt(amount, 10))
+ }
+ body, err := s.post(ctx, "/v1/refunds", form)
+ if err != nil {
+ return RefundResult{}, err
+ }
+ return RefundResult{Provider: s.Name(), Reference: strField(body, "id"), Amount: amount}, nil
+}
+
+func (s *StripeProvider) Void(ctx context.Context, authRef string) error {
+ if authRef == "" {
+ return fmt.Errorf("stripe: void requires a payment intent id")
+ }
+ _, err := s.post(ctx, "/v1/payment_intents/"+url.PathEscape(authRef)+"/cancel", url.Values{})
+ return err
+}
diff --git a/pkg/order/stripe_test.go b/pkg/order/stripe_test.go
new file mode 100644
index 0000000..c9eb8bf
--- /dev/null
+++ b/pkg/order/stripe_test.go
@@ -0,0 +1,91 @@
+package order
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestStripeProviderFlow(t *testing.T) {
+ var seen []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ seen = append(seen, r.Method+" "+r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.URL.Path == "/v1/payment_intents":
+ if r.Form.Get("capture_method") != "manual" || r.Form.Get("confirm") != "true" {
+ t.Errorf("authorize missing manual-capture/confirm: %v", r.Form)
+ }
+ if r.Form.Get("amount") != "25000" || r.Form.Get("currency") != "sek" {
+ t.Errorf("authorize bad amount/currency: %v", r.Form)
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "pi_123", "status": "requires_capture"})
+ case strings.HasSuffix(r.URL.Path, "/capture"):
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "pi_123", "status": "succeeded"})
+ case r.URL.Path == "/v1/refunds":
+ if r.Form.Get("payment_intent") != "pi_123" {
+ t.Errorf("refund missing payment_intent: %v", r.Form)
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "re_1"})
+ case strings.HasSuffix(r.URL.Path, "/cancel"):
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "pi_123", "status": "canceled"})
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ }))
+ defer srv.Close()
+
+ p := NewStripeProvider("sk_test_x", srv.URL, "pm_card_visa")
+ ctx := context.Background()
+
+ auth, err := p.Authorize(ctx, "ord-1", 25000, "SEK")
+ if err != nil {
+ t.Fatalf("authorize: %v", err)
+ }
+ if auth.Reference != "pi_123" || auth.Provider != "stripe" {
+ t.Fatalf("unexpected authorization %+v", auth)
+ }
+ if _, err := p.Capture(ctx, auth.Reference, 25000); err != nil {
+ t.Fatalf("capture: %v", err)
+ }
+ ref, err := p.Refund(ctx, auth.Reference, 1000)
+ if err != nil {
+ t.Fatalf("refund: %v", err)
+ }
+ if ref.Reference != "re_1" {
+ t.Fatalf("refund ref = %q", ref.Reference)
+ }
+ if err := p.Void(ctx, auth.Reference); err != nil {
+ t.Fatalf("void: %v", err)
+ }
+
+ want := []string{
+ "POST /v1/payment_intents",
+ "POST /v1/payment_intents/pi_123/capture",
+ "POST /v1/refunds",
+ "POST /v1/payment_intents/pi_123/cancel",
+ }
+ if strings.Join(seen, ",") != strings.Join(want, ",") {
+ t.Fatalf("endpoints called = %v, want %v", seen, want)
+ }
+}
+
+func TestStripeErrorSurfaced(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": map[string]any{"message": "Your card was declined."},
+ })
+ }))
+ defer srv.Close()
+
+ p := NewStripeProvider("sk_test_x", srv.URL, "pm_card_chargeDeclined")
+ _, err := p.Authorize(context.Background(), "ord-2", 100, "SEK")
+ if err == nil || !strings.Contains(err.Error(), "declined") {
+ t.Fatalf("expected declined error, got %v", err)
+ }
+}
diff --git a/proto/order.proto b/proto/order.proto
new file mode 100644
index 0000000..dd82068
--- /dev/null
+++ b/proto/order.proto
@@ -0,0 +1,98 @@
+syntax = "proto3";
+package order_messages;
+option go_package = "git.k6n.net/mats/go-cart-actor/proto/order;order_messages";
+
+// Order mutations. Each message is one immutable event in an order's history.
+// The grain's event log (actor.DiskStorage) is the source of truth; the current
+// OrderGrain state is a projection folded by replaying these in order. The
+// order state machine (pkg/order) guards which mutation is legal in which state.
+//
+// Timestamps are carried as unix-millis fields set by the caller so that replay
+// is deterministic (the registry does not pass the event timestamp to handlers).
+
+message OrderLine {
+ string reference = 1; // stable line reference (cart line id / sku)
+ string sku = 2;
+ string name = 3;
+ int32 quantity = 4;
+ int64 unit_price = 5; // inc-vat, minor units
+ int32 tax_rate = 6; // percent
+ int64 total_amount = 7;
+ int64 total_tax = 8;
+}
+
+// PlaceOrder creates the order (only legal when the order does not yet exist).
+message PlaceOrder {
+ string order_reference = 1; // external reference (e.g. checkout/cart id)
+ string cart_id = 2;
+ string currency = 3;
+ string locale = 4;
+ string country = 5;
+ int64 total_amount = 6; // inc-vat, minor units
+ int64 total_tax = 7;
+ repeated OrderLine lines = 8;
+ string customer_email = 9;
+ string customer_name = 10;
+ bytes billing_address = 11; // raw JSON, stored losslessly
+ bytes shipping_address = 12; // raw JSON, stored losslessly
+ int64 placed_at_ms = 13;
+}
+
+// AuthorizePayment records a successful authorization against a provider.
+message AuthorizePayment {
+ string provider = 1;
+ int64 amount = 2;
+ string reference = 3; // processor authorization reference
+ int64 at_ms = 4;
+}
+
+// CapturePayment records a (possibly partial) capture against an authorization.
+message CapturePayment {
+ string provider = 1;
+ int64 amount = 2;
+ string reference = 3; // processor capture reference
+ int64 at_ms = 4;
+}
+
+message FulfillmentLine {
+ string reference = 1;
+ int32 quantity = 2;
+}
+
+// CreateFulfillment ships some or all of the order's lines.
+message CreateFulfillment {
+ string id = 1;
+ string carrier = 2;
+ string tracking_number = 3;
+ string tracking_uri = 4;
+ repeated FulfillmentLine lines = 5;
+ int64 at_ms = 6;
+}
+
+// CompleteOrder marks a fully-fulfilled order complete.
+message CompleteOrder {
+ int64 at_ms = 1;
+}
+
+// CancelOrder cancels an order that has not yet been captured.
+message CancelOrder {
+ string reason = 1;
+ int64 at_ms = 2;
+}
+
+// RequestReturn opens an RMA against fulfilled lines (does not change status).
+message RequestReturn {
+ string id = 1;
+ string reason = 2;
+ repeated FulfillmentLine lines = 3;
+ int64 at_ms = 4;
+}
+
+// IssueRefund records a (possibly partial) refund against a captured payment.
+message IssueRefund {
+ string provider = 1;
+ int64 amount = 2;
+ string reference = 3; // processor refund reference
+ string return_id = 4; // optional linked RMA
+ int64 at_ms = 5;
+}
diff --git a/proto/order/order.pb.go b/proto/order/order.pb.go
new file mode 100644
index 0000000..ab9c562
--- /dev/null
+++ b/proto/order/order.pb.go
@@ -0,0 +1,955 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.5
+// protoc v7.35.0
+// source: order.proto
+
+package order_messages
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type OrderLine struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Reference string `protobuf:"bytes,1,opt,name=reference,proto3" json:"reference,omitempty"` // stable line reference (cart line id / sku)
+ Sku string `protobuf:"bytes,2,opt,name=sku,proto3" json:"sku,omitempty"`
+ Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
+ Quantity int32 `protobuf:"varint,4,opt,name=quantity,proto3" json:"quantity,omitempty"`
+ UnitPrice int64 `protobuf:"varint,5,opt,name=unit_price,json=unitPrice,proto3" json:"unit_price,omitempty"` // inc-vat, minor units
+ TaxRate int32 `protobuf:"varint,6,opt,name=tax_rate,json=taxRate,proto3" json:"tax_rate,omitempty"` // percent
+ TotalAmount int64 `protobuf:"varint,7,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"`
+ TotalTax int64 `protobuf:"varint,8,opt,name=total_tax,json=totalTax,proto3" json:"total_tax,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *OrderLine) Reset() {
+ *x = OrderLine{}
+ mi := &file_order_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *OrderLine) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*OrderLine) ProtoMessage() {}
+
+func (x *OrderLine) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use OrderLine.ProtoReflect.Descriptor instead.
+func (*OrderLine) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *OrderLine) GetReference() string {
+ if x != nil {
+ return x.Reference
+ }
+ return ""
+}
+
+func (x *OrderLine) GetSku() string {
+ if x != nil {
+ return x.Sku
+ }
+ return ""
+}
+
+func (x *OrderLine) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *OrderLine) GetQuantity() int32 {
+ if x != nil {
+ return x.Quantity
+ }
+ return 0
+}
+
+func (x *OrderLine) GetUnitPrice() int64 {
+ if x != nil {
+ return x.UnitPrice
+ }
+ return 0
+}
+
+func (x *OrderLine) GetTaxRate() int32 {
+ if x != nil {
+ return x.TaxRate
+ }
+ return 0
+}
+
+func (x *OrderLine) GetTotalAmount() int64 {
+ if x != nil {
+ return x.TotalAmount
+ }
+ return 0
+}
+
+func (x *OrderLine) GetTotalTax() int64 {
+ if x != nil {
+ return x.TotalTax
+ }
+ return 0
+}
+
+// PlaceOrder creates the order (only legal when the order does not yet exist).
+type PlaceOrder struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ OrderReference string `protobuf:"bytes,1,opt,name=order_reference,json=orderReference,proto3" json:"order_reference,omitempty"` // external reference (e.g. checkout/cart id)
+ CartId string `protobuf:"bytes,2,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"`
+ Currency string `protobuf:"bytes,3,opt,name=currency,proto3" json:"currency,omitempty"`
+ Locale string `protobuf:"bytes,4,opt,name=locale,proto3" json:"locale,omitempty"`
+ Country string `protobuf:"bytes,5,opt,name=country,proto3" json:"country,omitempty"`
+ TotalAmount int64 `protobuf:"varint,6,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"` // inc-vat, minor units
+ TotalTax int64 `protobuf:"varint,7,opt,name=total_tax,json=totalTax,proto3" json:"total_tax,omitempty"`
+ Lines []*OrderLine `protobuf:"bytes,8,rep,name=lines,proto3" json:"lines,omitempty"`
+ CustomerEmail string `protobuf:"bytes,9,opt,name=customer_email,json=customerEmail,proto3" json:"customer_email,omitempty"`
+ CustomerName string `protobuf:"bytes,10,opt,name=customer_name,json=customerName,proto3" json:"customer_name,omitempty"`
+ BillingAddress []byte `protobuf:"bytes,11,opt,name=billing_address,json=billingAddress,proto3" json:"billing_address,omitempty"` // raw JSON, stored losslessly
+ ShippingAddress []byte `protobuf:"bytes,12,opt,name=shipping_address,json=shippingAddress,proto3" json:"shipping_address,omitempty"` // raw JSON, stored losslessly
+ PlacedAtMs int64 `protobuf:"varint,13,opt,name=placed_at_ms,json=placedAtMs,proto3" json:"placed_at_ms,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *PlaceOrder) Reset() {
+ *x = PlaceOrder{}
+ mi := &file_order_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *PlaceOrder) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PlaceOrder) ProtoMessage() {}
+
+func (x *PlaceOrder) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use PlaceOrder.ProtoReflect.Descriptor instead.
+func (*PlaceOrder) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *PlaceOrder) GetOrderReference() string {
+ if x != nil {
+ return x.OrderReference
+ }
+ return ""
+}
+
+func (x *PlaceOrder) GetCartId() string {
+ if x != nil {
+ return x.CartId
+ }
+ return ""
+}
+
+func (x *PlaceOrder) GetCurrency() string {
+ if x != nil {
+ return x.Currency
+ }
+ return ""
+}
+
+func (x *PlaceOrder) GetLocale() string {
+ if x != nil {
+ return x.Locale
+ }
+ return ""
+}
+
+func (x *PlaceOrder) GetCountry() string {
+ if x != nil {
+ return x.Country
+ }
+ return ""
+}
+
+func (x *PlaceOrder) GetTotalAmount() int64 {
+ if x != nil {
+ return x.TotalAmount
+ }
+ return 0
+}
+
+func (x *PlaceOrder) GetTotalTax() int64 {
+ if x != nil {
+ return x.TotalTax
+ }
+ return 0
+}
+
+func (x *PlaceOrder) GetLines() []*OrderLine {
+ if x != nil {
+ return x.Lines
+ }
+ return nil
+}
+
+func (x *PlaceOrder) GetCustomerEmail() string {
+ if x != nil {
+ return x.CustomerEmail
+ }
+ return ""
+}
+
+func (x *PlaceOrder) GetCustomerName() string {
+ if x != nil {
+ return x.CustomerName
+ }
+ return ""
+}
+
+func (x *PlaceOrder) GetBillingAddress() []byte {
+ if x != nil {
+ return x.BillingAddress
+ }
+ return nil
+}
+
+func (x *PlaceOrder) GetShippingAddress() []byte {
+ if x != nil {
+ return x.ShippingAddress
+ }
+ return nil
+}
+
+func (x *PlaceOrder) GetPlacedAtMs() int64 {
+ if x != nil {
+ return x.PlacedAtMs
+ }
+ return 0
+}
+
+// AuthorizePayment records a successful authorization against a provider.
+type AuthorizePayment struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"`
+ Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
+ Reference string `protobuf:"bytes,3,opt,name=reference,proto3" json:"reference,omitempty"` // processor authorization reference
+ AtMs int64 `protobuf:"varint,4,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *AuthorizePayment) Reset() {
+ *x = AuthorizePayment{}
+ mi := &file_order_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *AuthorizePayment) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AuthorizePayment) ProtoMessage() {}
+
+func (x *AuthorizePayment) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use AuthorizePayment.ProtoReflect.Descriptor instead.
+func (*AuthorizePayment) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *AuthorizePayment) GetProvider() string {
+ if x != nil {
+ return x.Provider
+ }
+ return ""
+}
+
+func (x *AuthorizePayment) GetAmount() int64 {
+ if x != nil {
+ return x.Amount
+ }
+ return 0
+}
+
+func (x *AuthorizePayment) GetReference() string {
+ if x != nil {
+ return x.Reference
+ }
+ return ""
+}
+
+func (x *AuthorizePayment) GetAtMs() int64 {
+ if x != nil {
+ return x.AtMs
+ }
+ return 0
+}
+
+// CapturePayment records a (possibly partial) capture against an authorization.
+type CapturePayment struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"`
+ Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
+ Reference string `protobuf:"bytes,3,opt,name=reference,proto3" json:"reference,omitempty"` // processor capture reference
+ AtMs int64 `protobuf:"varint,4,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CapturePayment) Reset() {
+ *x = CapturePayment{}
+ mi := &file_order_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CapturePayment) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CapturePayment) ProtoMessage() {}
+
+func (x *CapturePayment) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[3]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CapturePayment.ProtoReflect.Descriptor instead.
+func (*CapturePayment) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *CapturePayment) GetProvider() string {
+ if x != nil {
+ return x.Provider
+ }
+ return ""
+}
+
+func (x *CapturePayment) GetAmount() int64 {
+ if x != nil {
+ return x.Amount
+ }
+ return 0
+}
+
+func (x *CapturePayment) GetReference() string {
+ if x != nil {
+ return x.Reference
+ }
+ return ""
+}
+
+func (x *CapturePayment) GetAtMs() int64 {
+ if x != nil {
+ return x.AtMs
+ }
+ return 0
+}
+
+type FulfillmentLine struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Reference string `protobuf:"bytes,1,opt,name=reference,proto3" json:"reference,omitempty"`
+ Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FulfillmentLine) Reset() {
+ *x = FulfillmentLine{}
+ mi := &file_order_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FulfillmentLine) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FulfillmentLine) ProtoMessage() {}
+
+func (x *FulfillmentLine) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[4]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FulfillmentLine.ProtoReflect.Descriptor instead.
+func (*FulfillmentLine) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *FulfillmentLine) GetReference() string {
+ if x != nil {
+ return x.Reference
+ }
+ return ""
+}
+
+func (x *FulfillmentLine) GetQuantity() int32 {
+ if x != nil {
+ return x.Quantity
+ }
+ return 0
+}
+
+// CreateFulfillment ships some or all of the order's lines.
+type CreateFulfillment struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Carrier string `protobuf:"bytes,2,opt,name=carrier,proto3" json:"carrier,omitempty"`
+ TrackingNumber string `protobuf:"bytes,3,opt,name=tracking_number,json=trackingNumber,proto3" json:"tracking_number,omitempty"`
+ TrackingUri string `protobuf:"bytes,4,opt,name=tracking_uri,json=trackingUri,proto3" json:"tracking_uri,omitempty"`
+ Lines []*FulfillmentLine `protobuf:"bytes,5,rep,name=lines,proto3" json:"lines,omitempty"`
+ AtMs int64 `protobuf:"varint,6,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CreateFulfillment) Reset() {
+ *x = CreateFulfillment{}
+ mi := &file_order_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CreateFulfillment) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CreateFulfillment) ProtoMessage() {}
+
+func (x *CreateFulfillment) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[5]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CreateFulfillment.ProtoReflect.Descriptor instead.
+func (*CreateFulfillment) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *CreateFulfillment) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *CreateFulfillment) GetCarrier() string {
+ if x != nil {
+ return x.Carrier
+ }
+ return ""
+}
+
+func (x *CreateFulfillment) GetTrackingNumber() string {
+ if x != nil {
+ return x.TrackingNumber
+ }
+ return ""
+}
+
+func (x *CreateFulfillment) GetTrackingUri() string {
+ if x != nil {
+ return x.TrackingUri
+ }
+ return ""
+}
+
+func (x *CreateFulfillment) GetLines() []*FulfillmentLine {
+ if x != nil {
+ return x.Lines
+ }
+ return nil
+}
+
+func (x *CreateFulfillment) GetAtMs() int64 {
+ if x != nil {
+ return x.AtMs
+ }
+ return 0
+}
+
+// CompleteOrder marks a fully-fulfilled order complete.
+type CompleteOrder struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ AtMs int64 `protobuf:"varint,1,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CompleteOrder) Reset() {
+ *x = CompleteOrder{}
+ mi := &file_order_proto_msgTypes[6]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CompleteOrder) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CompleteOrder) ProtoMessage() {}
+
+func (x *CompleteOrder) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[6]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CompleteOrder.ProtoReflect.Descriptor instead.
+func (*CompleteOrder) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *CompleteOrder) GetAtMs() int64 {
+ if x != nil {
+ return x.AtMs
+ }
+ return 0
+}
+
+// CancelOrder cancels an order that has not yet been captured.
+type CancelOrder struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"`
+ AtMs int64 `protobuf:"varint,2,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CancelOrder) Reset() {
+ *x = CancelOrder{}
+ mi := &file_order_proto_msgTypes[7]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CancelOrder) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CancelOrder) ProtoMessage() {}
+
+func (x *CancelOrder) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[7]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CancelOrder.ProtoReflect.Descriptor instead.
+func (*CancelOrder) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *CancelOrder) GetReason() string {
+ if x != nil {
+ return x.Reason
+ }
+ return ""
+}
+
+func (x *CancelOrder) GetAtMs() int64 {
+ if x != nil {
+ return x.AtMs
+ }
+ return 0
+}
+
+// RequestReturn opens an RMA against fulfilled lines (does not change status).
+type RequestReturn struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
+ Lines []*FulfillmentLine `protobuf:"bytes,3,rep,name=lines,proto3" json:"lines,omitempty"`
+ AtMs int64 `protobuf:"varint,4,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RequestReturn) Reset() {
+ *x = RequestReturn{}
+ mi := &file_order_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RequestReturn) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RequestReturn) ProtoMessage() {}
+
+func (x *RequestReturn) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[8]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RequestReturn.ProtoReflect.Descriptor instead.
+func (*RequestReturn) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *RequestReturn) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *RequestReturn) GetReason() string {
+ if x != nil {
+ return x.Reason
+ }
+ return ""
+}
+
+func (x *RequestReturn) GetLines() []*FulfillmentLine {
+ if x != nil {
+ return x.Lines
+ }
+ return nil
+}
+
+func (x *RequestReturn) GetAtMs() int64 {
+ if x != nil {
+ return x.AtMs
+ }
+ return 0
+}
+
+// IssueRefund records a (possibly partial) refund against a captured payment.
+type IssueRefund struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"`
+ Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
+ Reference string `protobuf:"bytes,3,opt,name=reference,proto3" json:"reference,omitempty"` // processor refund reference
+ ReturnId string `protobuf:"bytes,4,opt,name=return_id,json=returnId,proto3" json:"return_id,omitempty"` // optional linked RMA
+ AtMs int64 `protobuf:"varint,5,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *IssueRefund) Reset() {
+ *x = IssueRefund{}
+ mi := &file_order_proto_msgTypes[9]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *IssueRefund) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IssueRefund) ProtoMessage() {}
+
+func (x *IssueRefund) ProtoReflect() protoreflect.Message {
+ mi := &file_order_proto_msgTypes[9]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use IssueRefund.ProtoReflect.Descriptor instead.
+func (*IssueRefund) Descriptor() ([]byte, []int) {
+ return file_order_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *IssueRefund) GetProvider() string {
+ if x != nil {
+ return x.Provider
+ }
+ return ""
+}
+
+func (x *IssueRefund) GetAmount() int64 {
+ if x != nil {
+ return x.Amount
+ }
+ return 0
+}
+
+func (x *IssueRefund) GetReference() string {
+ if x != nil {
+ return x.Reference
+ }
+ return ""
+}
+
+func (x *IssueRefund) GetReturnId() string {
+ if x != nil {
+ return x.ReturnId
+ }
+ return ""
+}
+
+func (x *IssueRefund) GetAtMs() int64 {
+ if x != nil {
+ return x.AtMs
+ }
+ return 0
+}
+
+var File_order_proto protoreflect.FileDescriptor
+
+var file_order_proto_rawDesc = string([]byte{
+ 0x0a, 0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x6f,
+ 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xe5, 0x01,
+ 0x0a, 0x09, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72,
+ 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+ 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x6b, 0x75, 0x12, 0x12, 0x0a, 0x04, 0x6e,
+ 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12,
+ 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x75,
+ 0x6e, 0x69, 0x74, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52,
+ 0x09, 0x75, 0x6e, 0x69, 0x74, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x61,
+ 0x78, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x74, 0x61,
+ 0x78, 0x52, 0x61, 0x74, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61,
+ 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74,
+ 0x61, 0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61,
+ 0x6c, 0x5f, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74,
+ 0x61, 0x6c, 0x54, 0x61, 0x78, 0x22, 0xcf, 0x03, 0x0a, 0x0a, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x4f,
+ 0x72, 0x64, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x72, 0x65,
+ 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f,
+ 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x17, 0x0a,
+ 0x07, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
+ 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
+ 0x63, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
+ 0x63, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f,
+ 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75,
+ 0x6e, 0x74, 0x72, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d,
+ 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61,
+ 0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c,
+ 0x5f, 0x74, 0x61, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, 0x61,
+ 0x6c, 0x54, 0x61, 0x78, 0x12, 0x2f, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x08, 0x20,
+ 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
+ 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05,
+ 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65,
+ 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63,
+ 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d,
+ 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x4e, 0x61, 0x6d,
+ 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64,
+ 0x72, 0x65, 0x73, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, 0x6c,
+ 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68,
+ 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0c,
+ 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64,
+ 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x5f,
+ 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x6c, 0x61,
+ 0x63, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0x79, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f,
+ 0x72, 0x69, 0x7a, 0x65, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70,
+ 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70,
+ 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e,
+ 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12,
+ 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a,
+ 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74,
+ 0x4d, 0x73, 0x22, 0x77, 0x0a, 0x0e, 0x43, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x50, 0x61, 0x79,
+ 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
+ 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
+ 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65,
+ 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66,
+ 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
+ 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x4b, 0x0a, 0x0f, 0x46,
+ 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c,
+ 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08,
+ 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08,
+ 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xd5, 0x01, 0x0a, 0x11, 0x43, 0x72, 0x65,
+ 0x61, 0x74, 0x65, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x0e,
+ 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18,
+ 0x0a, 0x07, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x07, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x72, 0x61, 0x63,
+ 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x4e, 0x75, 0x6d, 0x62, 0x65,
+ 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x72,
+ 0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e,
+ 0x67, 0x55, 0x72, 0x69, 0x12, 0x35, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20,
+ 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
+ 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74,
+ 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61,
+ 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73,
+ 0x22, 0x24, 0x0a, 0x0d, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x72, 0x64, 0x65,
+ 0x72, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
+ 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x3a, 0x0a, 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c,
+ 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x13, 0x0a,
+ 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74,
+ 0x4d, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65,
+ 0x74, 0x75, 0x72, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x05,
+ 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72,
+ 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c,
+ 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69,
+ 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01,
+ 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x91, 0x01, 0x0a, 0x0b, 0x49, 0x73, 0x73,
+ 0x75, 0x65, 0x52, 0x65, 0x66, 0x75, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76,
+ 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76,
+ 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09,
+ 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65,
+ 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72,
+ 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73,
+ 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39,
+ 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73,
+ 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70,
+ 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72,
+ 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+ 0x33,
+})
+
+var (
+ file_order_proto_rawDescOnce sync.Once
+ file_order_proto_rawDescData []byte
+)
+
+func file_order_proto_rawDescGZIP() []byte {
+ file_order_proto_rawDescOnce.Do(func() {
+ file_order_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_order_proto_rawDesc), len(file_order_proto_rawDesc)))
+ })
+ return file_order_proto_rawDescData
+}
+
+var file_order_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
+var file_order_proto_goTypes = []any{
+ (*OrderLine)(nil), // 0: order_messages.OrderLine
+ (*PlaceOrder)(nil), // 1: order_messages.PlaceOrder
+ (*AuthorizePayment)(nil), // 2: order_messages.AuthorizePayment
+ (*CapturePayment)(nil), // 3: order_messages.CapturePayment
+ (*FulfillmentLine)(nil), // 4: order_messages.FulfillmentLine
+ (*CreateFulfillment)(nil), // 5: order_messages.CreateFulfillment
+ (*CompleteOrder)(nil), // 6: order_messages.CompleteOrder
+ (*CancelOrder)(nil), // 7: order_messages.CancelOrder
+ (*RequestReturn)(nil), // 8: order_messages.RequestReturn
+ (*IssueRefund)(nil), // 9: order_messages.IssueRefund
+}
+var file_order_proto_depIdxs = []int32{
+ 0, // 0: order_messages.PlaceOrder.lines:type_name -> order_messages.OrderLine
+ 4, // 1: order_messages.CreateFulfillment.lines:type_name -> order_messages.FulfillmentLine
+ 4, // 2: order_messages.RequestReturn.lines:type_name -> order_messages.FulfillmentLine
+ 3, // [3:3] is the sub-list for method output_type
+ 3, // [3:3] is the sub-list for method input_type
+ 3, // [3:3] is the sub-list for extension type_name
+ 3, // [3:3] is the sub-list for extension extendee
+ 0, // [0:3] is the sub-list for field type_name
+}
+
+func init() { file_order_proto_init() }
+func file_order_proto_init() {
+ if File_order_proto != nil {
+ return
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_order_proto_rawDesc), len(file_order_proto_rawDesc)),
+ NumEnums: 0,
+ NumMessages: 10,
+ NumExtensions: 0,
+ NumServices: 0,
+ },
+ GoTypes: file_order_proto_goTypes,
+ DependencyIndexes: file_order_proto_depIdxs,
+ MessageInfos: file_order_proto_msgTypes,
+ }.Build()
+ File_order_proto = out.File
+ file_order_proto_goTypes = nil
+ file_order_proto_depIdxs = nil
+}