order sagas
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-21 11:33:20 +02:00
parent 484f2364fb
commit 1a365de071
25 changed files with 4224 additions and 1 deletions
+7
View File
@@ -85,6 +85,12 @@ RUN go build -trimpath -ldflags="-s -w \
-X main.BuildDate=${BUILD_DATE}" \ -X main.BuildDate=${BUILD_DATE}" \
-o /out/go-checkout-actor ./cmd/checkout -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 # 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-checkout-actor /go-checkout-actor
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice 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-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) # Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
EXPOSE 8080 1337 EXPOSE 8080 1337
+6 -1
View File
@@ -19,10 +19,11 @@
MODULE_PATH := git.k6n.net/mats/go-cart-actor MODULE_PATH := git.k6n.net/mats/go-cart-actor
PROTO_DIR := proto 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 CART_PROTO_DIR := $(PROTO_DIR)/cart
CONTROL_PROTO_DIR := $(PROTO_DIR)/control CONTROL_PROTO_DIR := $(PROTO_DIR)/control
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
ORDER_PROTO_DIR := $(PROTO_DIR)/order
# Allow override: make PROTOC=/path/to/protoc # Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc PROTOC ?= protoc
@@ -83,6 +84,10 @@ protogen: check_tools
--go_out=./proto/checkout --go_opt=paths=source_relative \ --go_out=./proto/checkout --go_opt=paths=source_relative \
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \ --go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/checkout.proto $(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)" @echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto: clean_proto:
+190
View File
@@ -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)
}
}
}
}()
}
+73
View File
@@ -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))
}
}
+111
View File
@@ -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 <dir>/<name>.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
}
+231
View File
@@ -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)
}
+444
View File
@@ -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()})
}
+301
View File
@@ -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)
}
}
}
+91
View File
@@ -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)
}
}
+67
View File
@@ -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
}
+89
View File
@@ -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
}
}
+231
View File
@@ -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
})
}
+96
View File
@@ -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)
}
}
+122
View File
@@ -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)
}
}
+29
View File
@@ -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" } }]
}
}
]
}
+58
View File
@@ -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)
})
}
+71
View File
@@ -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:])
}
+249
View File
@@ -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-<authRef>"; 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),
)
}
+213
View File
@@ -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)
}
+169
View File
@@ -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)
}
}
+69
View File
@@ -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 }
+163
View File
@@ -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
}
+91
View File
@@ -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)
}
}
+98
View File
@@ -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;
}
+955
View File
@@ -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
}