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
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"context"
"encoding/json"
"hash/fnv"
"log/slog"
"sync"
"time"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
amqp "github.com/rabbitmq/amqp091-go"
)
// rabbitPublisher implements order.Publisher for the amqp_emit flow hook.
type rabbitPublisher struct {
conn *amqp.Connection
ch *amqp.Channel
mu sync.Mutex
}
func newRabbitPublisher(url string) (*rabbitPublisher, error) {
conn, err := amqp.Dial(url)
if err != nil {
return nil, err
}
ch, err := conn.Channel()
if err != nil {
_ = conn.Close()
return nil, err
}
return &rabbitPublisher{conn: conn, ch: ch}, nil
}
func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
return p.ch.PublishWithContext(context.Background(), exchange, routingKey, false, false,
amqp.Publishing{ContentType: "application/json", Body: body})
}
// This ingester lets the order service supersede the legacy go-order-manager:
// it consumes the same "order-queue" the Klarna/Adyen checkout publishes to and
// records each completed order as an event-sourced grain. Those orders are
// already paid by the external processor, so we record payment with provider
// "legacy" (place -> authorize -> capture) rather than charging again.
// legacyOrder is the subset of go-order-manager's Order JSON we need.
type legacyOrder struct {
ID string `json:"order_id"`
PurchaseCountry string `json:"purchase_country"`
PurchaseCurrency string `json:"purchase_currency"`
Locale string `json:"locale"`
OrderAmount int64 `json:"order_amount"`
OrderTaxAmount int64 `json:"order_tax_amount"`
OrderLines []legacyLine `json:"order_lines"`
Customer *legacyPerson `json:"customer,omitempty"`
BillingAddress *legacyAddr `json:"billing_address,omitempty"`
}
type legacyLine struct {
Reference string `json:"reference"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unit_price"`
TaxRate int32 `json:"tax_rate"`
TotalAmount int64 `json:"total_amount"`
TotalTaxAmount int64 `json:"total_tax_amount"`
}
type legacyPerson struct {
Email string `json:"email,omitempty"`
}
type legacyAddr struct {
Email string `json:"email,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
}
// legacyOrderID maps a legacy string order id to a stable grain id, so a
// redelivered message resolves to the same grain (PlaceOrder then no-ops).
func legacyOrderID(ref string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(ref))
id := h.Sum64()
if id == 0 {
id = 1
}
return id
}
// ingestLegacyOrder records one legacy order as an event-sourced grain. It is
// idempotent: if the order is already placed, PlaceOrder is rejected and we
// stop (the order already exists). Testable without a broker.
func ingestLegacyOrder(ctx context.Context, app *orderedApplier, body []byte) error {
var lo legacyOrder
if err := json.Unmarshal(body, &lo); err != nil {
return err
}
if lo.ID == "" || len(lo.OrderLines) == 0 {
return nil // nothing actionable
}
id := legacyOrderID(lo.ID)
ms := time.Now().UnixMilli()
po := &messages.PlaceOrder{
OrderReference: lo.ID,
Currency: lo.PurchaseCurrency,
Locale: lo.Locale,
Country: lo.PurchaseCountry,
TotalAmount: lo.OrderAmount,
TotalTax: lo.OrderTaxAmount,
PlacedAtMs: ms,
}
if lo.Customer != nil {
po.CustomerEmail = lo.Customer.Email
}
if po.CustomerEmail == "" && lo.BillingAddress != nil {
po.CustomerEmail = lo.BillingAddress.Email
po.CustomerName = lo.BillingAddress.GivenName + " " + lo.BillingAddress.FamilyName
}
for _, l := range lo.OrderLines {
po.Lines = append(po.Lines, &messages.OrderLine{
Reference: l.Reference, Sku: l.Reference, Name: l.Name,
Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate,
TotalAmount: l.TotalAmount, TotalTax: l.TotalTaxAmount,
})
}
if err := applyOne(ctx, app, id, po); err != nil {
// Already placed (redelivery) or invalid — nothing more to do.
return nil
}
// Record the externally-settled payment so the grain reaches "captured".
ref := "legacy-" + lo.ID
_ = applyOne(ctx, app, id, &messages.AuthorizePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
_ = applyOne(ctx, app, id, &messages.CapturePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
return nil
}
// startLegacyIngest connects to AMQP and consumes "order-queue" until ctx is
// done. Best-effort: a connection failure is logged and ingestion is skipped
// (the HTTP checkout path keeps working regardless).
func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier, logger *slog.Logger) {
conn, err := amqp.Dial(amqpURL)
if err != nil {
logger.Warn("legacy order ingest disabled: amqp dial failed", "err", err)
return
}
ch, err := conn.Channel()
if err != nil {
logger.Warn("legacy order ingest disabled: channel failed", "err", err)
_ = conn.Close()
return
}
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
if err != nil {
logger.Warn("legacy order ingest disabled: queue declare failed", "err", err)
_ = ch.Close()
_ = conn.Close()
return
}
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
if err != nil {
logger.Warn("legacy order ingest disabled: consume failed", "err", err)
_ = ch.Close()
_ = conn.Close()
return
}
logger.Info("ingesting legacy orders from order-queue")
go func() {
defer conn.Close()
defer ch.Close()
for {
select {
case <-ctx.Done():
return
case d, ok := <-msgs:
if !ok {
logger.Warn("legacy order ingest: channel closed")
return
}
if err := ingestLegacyOrder(ctx, app, d.Body); err != nil {
logger.Error("legacy order ingest failed", "err", err)
}
}
}
}()
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"fmt"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
func testApplier(t *testing.T) *orderedApplier {
t.Helper()
reg := actor.NewMutationRegistry()
order.RegisterMutations(reg)
storage := actor.NewDiskStorage[order.OrderGrain](t.TempDir(), reg)
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
Hostname: "test",
Spawn: func(_ context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
return order.NewOrderGrain(id, time.Now()), nil
},
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) { return nil, fmt.Errorf("none") },
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
TTL: time.Hour,
PoolSize: 100,
MutationRegistry: reg,
Storage: nil,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
return &orderedApplier{pool: pool, storage: storage}
}
func TestIngestLegacyOrder(t *testing.T) {
app := testApplier(t)
ctx := context.Background()
body := []byte(`{
"order_id":"K-1","purchase_currency":"SEK","purchase_country":"se","locale":"sv-SE",
"order_amount":15000,"order_tax_amount":3000,
"order_lines":[{"reference":"l1","name":"Widget","quantity":1,"unit_price":15000,"tax_rate":25,"total_amount":15000,"total_tax_amount":3000}]
}`)
if err := ingestLegacyOrder(ctx, app, body); err != nil {
t.Fatalf("ingest: %v", err)
}
id := legacyOrderID("K-1")
g, err := app.Get(ctx, id)
if err != nil {
t.Fatal(err)
}
if g.Status != order.StatusCaptured {
t.Fatalf("status = %q, want captured", g.Status)
}
if g.CapturedAmount != 15000 {
t.Fatalf("captured = %d, want 15000", g.CapturedAmount)
}
// Redelivery must be idempotent — no double capture, no new payment.
if err := ingestLegacyOrder(ctx, app, body); err != nil {
t.Fatalf("re-ingest: %v", err)
}
g2, _ := app.Get(ctx, id)
if g2.CapturedAmount != 15000 {
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
}
if len(g2.Payments) != 1 {
t.Fatalf("redelivery created %d payments, want 1", len(g2.Payments))
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
)
// flowStore holds the saga/flow definitions the service can run. Definitions
// are JSON files in a directory (editable by the backoffice saga editor),
// overlaid on a set of built-in seeds so a fresh deployment always has the
// defaults. Saving validates against the engine before writing.
type flowStore struct {
mu sync.RWMutex
dir string
engine *flow.Engine
defs map[string]*flow.Definition
}
var validFlowName = func(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
if !(r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
// newFlowStore seeds with the built-in definitions, then overlays any JSON files
// found in dir (on-disk wins, so edits persist and take effect).
func newFlowStore(dir string, engine *flow.Engine, seed map[string]*flow.Definition) (*flowStore, error) {
s := &flowStore{dir: dir, engine: engine, defs: map[string]*flow.Definition{}}
for name, def := range seed {
s.defs[name] = def
}
matches, _ := filepath.Glob(filepath.Join(dir, "*.json"))
for _, m := range matches {
data, err := os.ReadFile(m)
if err != nil {
return nil, fmt.Errorf("read flow %s: %w", m, err)
}
def, err := flow.Parse(data)
if err != nil {
return nil, fmt.Errorf("parse flow %s: %w", m, err)
}
if def.Name == "" {
def.Name = strings.TrimSuffix(filepath.Base(m), ".json")
}
s.defs[def.Name] = def
}
return s, nil
}
func (s *flowStore) get(name string) (*flow.Definition, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
d, ok := s.defs[name]
return d, ok
}
func (s *flowStore) list() []*flow.Definition {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.defs))
for n := range s.defs {
names = append(names, n)
}
sort.Strings(names)
out := make([]*flow.Definition, 0, len(names))
for _, n := range names {
out = append(out, s.defs[n])
}
return out
}
// save validates the definition, persists it to <dir>/<name>.json and updates
// the in-memory map so the next checkout uses it.
func (s *flowStore) save(name string, def *flow.Definition) error {
if !validFlowName(name) {
return fmt.Errorf("invalid flow name %q", name)
}
if def.Name == "" {
def.Name = name
}
if err := s.engine.Validate(def); err != nil {
return err
}
data, err := json.MarshalIndent(def, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(s.dir, name+".json"), append(data, '\n'), 0o644); err != nil {
return err
}
s.mu.Lock()
s.defs[name] = def
s.mu.Unlock()
return nil
}
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"google.golang.org/protobuf/proto"
)
func nowMs() int64 { return time.Now().UnixMilli() }
// applyOne applies a single mutation and surfaces the handler error (the pool
// returns a top-level error only for unregistered mutations; a rejected
// transition is carried per-mutation in the result).
func applyOne(ctx context.Context, app *orderedApplier, id uint64, msg proto.Message) error {
res, err := app.Apply(ctx, id, msg)
if err != nil {
return err
}
if res != nil {
for _, m := range res.Mutations {
if m.Error != nil {
return m.Error
}
}
}
return nil
}
// capturedRef returns the capture reference of the first captured payment, which
// the payment provider needs to issue a refund against.
func capturedRef(g *order.OrderGrain) string {
for _, p := range g.Payments {
if p.Captured > 0 && p.CaptureRef != "" {
return p.CaptureRef
}
}
return ""
}
// loadOrder parses the id and returns the current grain, writing 400/404 itself
// and returning ok=false when the caller should stop.
func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderId, *order.OrderGrain, bool) {
id, ok := order.ParseOrderId(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
return 0, nil, false
}
g, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return 0, nil, false
}
if g.Status == order.StatusNew {
writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
return 0, nil, false
}
return id, g, true
}
// applyAndRespond applies a mutation and returns the updated order. A rejected
// state transition (handler error) maps to 409 Conflict.
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message) {
res, err := s.applier.Apply(r.Context(), uint64(id), msg)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
for _, m := range res.Mutations {
if m.Error != nil {
writeErr(w, http.StatusConflict, m.Error)
return
}
}
grain, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
}
// --- lifecycle (b) --------------------------------------------------------
type fulfillReq struct {
Carrier string `json:"carrier,omitempty"`
TrackingNumber string `json:"trackingNumber,omitempty"`
TrackingURI string `json:"trackingUri,omitempty"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
var req fulfillReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
fid, _ := order.NewOrderId()
msg := &messages.CreateFulfillment{
Id: "f_" + fid.String(),
Carrier: req.Carrier,
TrackingNumber: req.TrackingNumber,
TrackingUri: req.TrackingURI,
AtMs: nowMs(),
}
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg)
}
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()})
}
func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
var req struct {
Reason string `json:"reason"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()})
}
type returnReq struct {
Reason string `json:"reason,omitempty"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
var req returnReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
rid, _ := order.NewOrderId()
msg := &messages.RequestReturn{Id: "r_" + rid.String(), Reason: req.Reason, AtMs: nowMs()}
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg)
}
func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
id, g, ok := s.loadOrder(w, r)
if !ok {
return
}
var req struct {
Amount int64 `json:"amount"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
amount := req.Amount
if amount <= 0 {
amount = g.CapturedAmount - g.RefundedAmount // default: full remaining
}
captureRef := capturedRef(g)
ref, err := s.provider.Refund(r.Context(), captureRef, amount)
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Errorf("refund failed: %w", err))
return
}
s.applyAndRespond(w, r, id, &messages.IssueRefund{
Provider: ref.Provider,
Amount: ref.Amount,
Reference: ref.Reference,
AtMs: nowMs(),
})
}
// --- saga / flow admin (a) ------------------------------------------------
func (s *server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.freg.Capabilities())
}
func (s *server) handleListFlows(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.flows.list())
}
func (s *server) handleGetFlow(w http.ResponseWriter, r *http.Request) {
def, ok := s.flows.get(r.PathValue("name"))
if !ok {
writeErr(w, http.StatusNotFound, fmt.Errorf("flow not found"))
return
}
writeJSON(w, http.StatusOK, def)
}
func (s *server) handleSaveFlow(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var def flow.Definition
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := s.flows.save(name, &def); err != nil {
// Validation failures (unknown action/hook/predicate) are client errors.
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, &def)
}
+444
View File
@@ -0,0 +1,444 @@
// Command order is a single-node HTTP entrypoint for the order grain. It exposes
// a simple checkout that creates an order by running the place-and-pay flow
// (place -> mock authorize -> capture) on the actor framework, plus read APIs
// for an order and its event timeline. No clustering, no external payment — the
// simplest path to a real, event-sourced, captured order.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
type server struct {
pool *actor.SimpleGrainPool[order.OrderGrain]
applier *orderedApplier
storage *actor.DiskStorage[order.OrderGrain]
reg actor.MutationRegistry
engine *flow.Engine
freg *flow.Registry
flows *flowStore
provider order.PaymentProvider
defaultFlow string
dataDir string
logger *slog.Logger
}
func main() {
addr := envOr("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector
dataDir := envOr("ORDER_DATA", "data/orders")
flowsDir := envOr("ORDER_FLOWS", "data/order-flows")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
log.Fatalf("create data dir: %v", err)
}
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
reg := actor.NewMutationRegistry()
order.RegisterMutations(reg)
storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg)
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
Hostname: "order-1",
Spawn: func(ctx context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
g := order.NewOrderGrain(id, time.Now())
// Replay any persisted history so the resident grain is current.
if err := storage.LoadEvents(ctx, id, g); err != nil {
return nil, err
}
return g, nil
},
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) {
return nil, fmt.Errorf("order service is single-node")
},
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
TTL: time.Hour,
PoolSize: 10000,
// Storage is intentionally nil here: the pool persists mutations in a
// fire-and-forget goroutine, so concurrent appends for one grain can land
// out of order on disk — fatal for an event log, where replay order is the
// state. We persist through orderedApplier instead (synchronous, ordered,
// and only for mutations that actually applied). Replay on spawn still uses
// `storage` directly in Spawn above.
MutationRegistry: reg,
Storage: nil,
})
if err != nil {
log.Fatalf("create pool: %v", err)
}
applier := &orderedApplier{pool: pool, storage: storage}
provider := selectProvider(logger)
// Flow registry: generic hooks + the order lifecycle actions + predicates,
// paying via the selected provider. The amqp_emit hook bridges saga steps to
// the broker (publisher is nil without AMQP — the hook then errors at run
// time, which is logged, not fatal).
amqpURL := os.Getenv("AMQP_URL")
var emitPub order.Publisher
if amqpURL != "" {
if pub, err := newRabbitPublisher(amqpURL); err != nil {
logger.Warn("amqp_emit hook: publisher unavailable", "err", err)
} else {
emitPub = pub
}
}
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterEmitHook(freg, emitPub)
engine := flow.NewEngine(freg, logger)
def, err := order.EmbeddedFlow("place-and-pay")
if err != nil {
log.Fatalf("load flow: %v", err)
}
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{def.Name: def})
if err != nil {
log.Fatalf("load flows: %v", err)
}
s := &server{
pool: pool, applier: applier, storage: storage, reg: reg,
engine: engine, freg: freg, flows: flows, provider: provider,
defaultFlow: def.Name, dataDir: dataDir, logger: logger,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
// Checkout + order reads.
mux.HandleFunc("POST /checkout", s.handleCheckout)
mux.HandleFunc("GET /api/orders", s.handleList)
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
// Post-purchase lifecycle (the grain already supports these transitions).
mux.HandleFunc("POST /api/orders/{id}/fulfillments", s.handleFulfill)
mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete)
mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel)
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
// Saga / flow admin (backoffice editor).
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
mux.HandleFunc("GET /sagas/flows", s.handleListFlows)
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
// Ingest legacy checkout orders (Klarna/Adyen → order-queue) so this service
// is the single source of orders, superseding go-order-manager.
if amqpURL != "" {
startLegacyIngest(context.Background(), amqpURL, applier, logger)
}
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("serve: %v", err)
}
}
// orderedApplier wraps the grain pool to persist the event log correctly: it
// appends each applied mutation synchronously and in call order (the pool's own
// storage path is async/unordered), and only persists mutations whose handler
// succeeded — a rejected transition must never enter the log. A single mutex
// serialises all appends, which is ample for a single-node checkout service.
type orderedApplier struct {
pool *actor.SimpleGrainPool[order.OrderGrain]
storage *actor.DiskStorage[order.OrderGrain]
mu sync.Mutex
}
func (a *orderedApplier) Get(ctx context.Context, id uint64) (*order.OrderGrain, error) {
return a.pool.Get(ctx, id)
}
func (a *orderedApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) {
res, err := a.pool.Apply(ctx, id, mutation...)
if err != nil || res == nil {
return res, err
}
// Persist only the mutations that actually applied (result index aligns with
// the input order).
applied := make([]proto.Message, 0, len(mutation))
for i, m := range mutation {
if i < len(res.Mutations) && res.Mutations[i].Error != nil {
continue
}
applied = append(applied, m)
}
if len(applied) > 0 {
a.mu.Lock()
appendErr := a.storage.AppendMutations(id, applied...)
a.mu.Unlock()
if appendErr != nil {
return res, appendErr
}
}
return res, nil
}
// --- checkout -------------------------------------------------------------
type lineReq struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
TaxRate int32 `json:"taxRate"` // percent
}
type checkoutReq struct {
OrderReference string `json:"orderReference,omitempty"`
CartId string `json:"cartId,omitempty"`
Currency string `json:"currency,omitempty"`
Locale string `json:"locale,omitempty"`
Country string `json:"country,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
Lines []lineReq `json:"lines"`
}
func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
var req checkoutReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("bad body: %w", err))
return
}
if len(req.Lines) == 0 {
writeErr(w, http.StatusBadRequest, fmt.Errorf("checkout requires at least one line"))
return
}
id, err := order.NewOrderId()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if req.OrderReference == "" {
req.OrderReference = "ord-" + id.String()
}
if req.Currency == "" {
req.Currency = "SEK"
}
flowName := r.URL.Query().Get("flow")
if flowName == "" {
flowName = s.defaultFlow
}
def, ok := s.flows.get(flowName)
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName))
return
}
po := buildPlaceOrder(id, &req)
st := flow.NewState(uint64(id), s.logger)
st.Vars[order.PlaceOrderVar] = po
res, runErr := s.engine.Run(r.Context(), def, st)
grain, getErr := s.pool.Get(r.Context(), uint64(id))
if getErr != nil {
writeErr(w, http.StatusInternalServerError, getErr)
return
}
status := http.StatusCreated
if runErr != nil {
// The flow compensated; the order exists but is not paid. Report 402 with
// the (now cancelled/failed) order and the flow trace so the caller knows.
status = http.StatusPaymentRequired
s.logger.Warn("checkout flow failed", "orderId", id.String(), "err", runErr)
}
writeJSON(w, status, map[string]any{
"orderId": id.String(),
"flow": res,
"order": grain,
})
}
// buildPlaceOrder converts a checkout request into the PlaceOrder event,
// computing per-line and order totals (inc-vat) from the lines.
func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
po := &messages.PlaceOrder{
OrderReference: req.OrderReference,
CartId: req.CartId,
Currency: req.Currency,
Locale: req.Locale,
Country: req.Country,
CustomerEmail: req.CustomerEmail,
CustomerName: req.CustomerName,
BillingAddress: req.BillingAddress,
ShippingAddress: req.ShippingAddress,
PlacedAtMs: time.Now().UnixMilli(),
}
var total, totalTax int64
for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity)
var lineTax int64
if l.TaxRate > 0 {
// inc-vat -> tax portion = total * rate / (100 + rate)
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
}
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{
Reference: l.Reference,
Sku: l.Sku,
Name: l.Name,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice,
TaxRate: l.TaxRate,
TotalAmount: lineTotal,
TotalTax: lineTax,
})
}
po.TotalAmount = total
po.TotalTax = totalTax
return po
}
// --- reads ----------------------------------------------------------------
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
id, ok := order.ParseOrderId(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
return
}
grain, err := s.pool.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if grain.Status == order.StatusNew {
writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
}
type eventView struct {
Type string `json:"type"`
Time string `json:"time,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// handleEvents returns the raw event timeline by reading the grain's append-only
// log (the source of truth) without re-applying it to live state.
func (s *server) handleEvents(w http.ResponseWriter, r *http.Request) {
id, ok := order.ParseOrderId(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
return
}
var events []eventView
throwaway := order.NewOrderGrain(uint64(id), time.Now())
err := s.storage.LoadEventsFunc(r.Context(), uint64(id), throwaway,
func(msg proto.Message, _ int, ts time.Time) bool {
name, _ := s.reg.GetTypeName(msg)
payload, _ := protojson.Marshal(msg)
events = append(events, eventView{Type: name, Time: ts.UTC().Format(time.RFC3339), Payload: payload})
return false // collect only; do not apply
})
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "events": events})
}
type orderSummary struct {
OrderId string `json:"orderId"`
Reference string `json:"reference,omitempty"`
Status order.Status `json:"status"`
TotalAmount int64 `json:"totalAmount"`
CapturedAmount int64 `json:"capturedAmount"`
Currency string `json:"currency,omitempty"`
PlacedAt string `json:"placedAt,omitempty"`
}
// handleList scans the data dir for order logs and summarises each (replaying it
// through the pool). Fine for a single-node simple checkout; a production list
// would use an index/projection store.
func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
out := make([]orderSummary, 0, len(matches))
for _, m := range matches {
base := strings.TrimSuffix(filepath.Base(m), ".events.log")
raw, err := strconv.ParseUint(base, 10, 64)
if err != nil {
continue
}
g, err := s.pool.Get(r.Context(), raw)
if err != nil || g.Status == order.StatusNew {
continue
}
out = append(out, orderSummary{
OrderId: order.OrderId(raw).String(),
Reference: g.OrderReference,
Status: g.Status,
TotalAmount: g.TotalAmount,
CapturedAmount: g.CapturedAmount,
Currency: g.Currency,
PlacedAt: g.PlacedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
// --- helpers --------------------------------------------------------------
// selectProvider picks the payment provider from the environment. Default is
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
func selectProvider(logger *slog.Logger) order.PaymentProvider {
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
key := os.Getenv("STRIPE_SECRET_KEY")
if key == "" {
logger.Warn("PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is empty; falling back to mock")
return order.NewMockProvider()
}
logger.Info("using stripe payment provider")
return order.NewStripeProvider(key, os.Getenv("STRIPE_API_BASE"), os.Getenv("STRIPE_PAYMENT_METHOD"))
}
return order.NewMockProvider()
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]string{"error": err.Error()})
}