order sagas
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" } }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user