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