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 +}