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