order sagas
This commit is contained in:
@@ -85,6 +85,12 @@ RUN go build -trimpath -ldflags="-s -w \
|
|||||||
-X main.BuildDate=${BUILD_DATE}" \
|
-X main.BuildDate=${BUILD_DATE}" \
|
||||||
-o /out/go-checkout-actor ./cmd/checkout
|
-o /out/go-checkout-actor ./cmd/checkout
|
||||||
|
|
||||||
|
RUN go build -trimpath -ldflags="-s -w \
|
||||||
|
-X main.Version=${VERSION} \
|
||||||
|
-X main.GitCommit=${GIT_COMMIT} \
|
||||||
|
-X main.BuildDate=${BUILD_DATE}" \
|
||||||
|
-o /out/go-order-actor ./cmd/order
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# Runtime Stage
|
# Runtime Stage
|
||||||
############################
|
############################
|
||||||
@@ -96,6 +102,7 @@ COPY --from=build /out/go-cart-actor /go-cart-actor
|
|||||||
COPY --from=build /out/go-checkout-actor /go-checkout-actor
|
COPY --from=build /out/go-checkout-actor /go-checkout-actor
|
||||||
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
|
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
|
||||||
COPY --from=build /out/go-cart-inventory /go-cart-inventory
|
COPY --from=build /out/go-cart-inventory /go-cart-inventory
|
||||||
|
COPY --from=build /out/go-order-actor /go-order-actor
|
||||||
|
|
||||||
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
||||||
EXPOSE 8080 1337
|
EXPOSE 8080 1337
|
||||||
|
|||||||
@@ -19,10 +19,11 @@
|
|||||||
|
|
||||||
MODULE_PATH := git.k6n.net/mats/go-cart-actor
|
MODULE_PATH := git.k6n.net/mats/go-cart-actor
|
||||||
PROTO_DIR := proto
|
PROTO_DIR := proto
|
||||||
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto
|
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto
|
||||||
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
||||||
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
||||||
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
|
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
|
||||||
|
ORDER_PROTO_DIR := $(PROTO_DIR)/order
|
||||||
|
|
||||||
# Allow override: make PROTOC=/path/to/protoc
|
# Allow override: make PROTOC=/path/to/protoc
|
||||||
PROTOC ?= protoc
|
PROTOC ?= protoc
|
||||||
@@ -83,6 +84,10 @@ protogen: check_tools
|
|||||||
--go_out=./proto/checkout --go_opt=paths=source_relative \
|
--go_out=./proto/checkout --go_opt=paths=source_relative \
|
||||||
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
|
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
|
||||||
$(PROTO_DIR)/checkout.proto
|
$(PROTO_DIR)/checkout.proto
|
||||||
|
$(PROTOC) -I $(PROTO_DIR) \
|
||||||
|
--go_out=./proto/order --go_opt=paths=source_relative \
|
||||||
|
--go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \
|
||||||
|
$(PROTO_DIR)/order.proto
|
||||||
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||||
|
|
||||||
clean_proto:
|
clean_proto:
|
||||||
|
|||||||
@@ -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()})
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package flow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase identifies when a hook fires relative to its step's action.
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhaseBefore Phase = "before"
|
||||||
|
PhaseAfter Phase = "after"
|
||||||
|
PhaseOnError Phase = "onError"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State is the mutable bag shared across a single flow run. Actions and hooks
|
||||||
|
// read and write Vars to pass data down the chain (e.g. an authorization
|
||||||
|
// reference produced by one step and consumed by the next). ID is the domain
|
||||||
|
// entity the flow operates on (an order id, for the order flows).
|
||||||
|
type State struct {
|
||||||
|
ID uint64
|
||||||
|
Vars map[string]any
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewState returns an empty state for entity id.
|
||||||
|
func NewState(id uint64, logger *slog.Logger) *State {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &State{ID: id, Vars: map[string]any{}, Logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action performs a step's work. params is the step's opaque JSON config.
|
||||||
|
type Action func(ctx context.Context, st *State, params json.RawMessage) error
|
||||||
|
|
||||||
|
// Predicate gates a step (the step runs only when it returns true).
|
||||||
|
type Predicate func(ctx context.Context, st *State) (bool, error)
|
||||||
|
|
||||||
|
// HookInfo describes the step and phase a hook is firing for.
|
||||||
|
type HookInfo struct {
|
||||||
|
Step string
|
||||||
|
Phase Phase
|
||||||
|
// Err is the action error when Phase == PhaseOnError, else nil.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook reacts around a step. Hook errors never abort the flow — they are logged
|
||||||
|
// — so observability/notification hooks can't break the business transaction.
|
||||||
|
type Hook func(ctx context.Context, st *State, info HookInfo, params json.RawMessage) error
|
||||||
|
|
||||||
|
// Registry holds the named actions, predicates and hooks a flow can reference.
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
actions map[string]Action
|
||||||
|
predicates map[string]Predicate
|
||||||
|
hooks map[string]Hook
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry returns an empty registry.
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{
|
||||||
|
actions: map[string]Action{},
|
||||||
|
predicates: map[string]Predicate{},
|
||||||
|
hooks: map[string]Hook{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action registers an action under name (overwrites an existing one).
|
||||||
|
func (r *Registry) Action(name string, fn Action) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.actions[name] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predicate registers a predicate under name.
|
||||||
|
func (r *Registry) Predicate(name string, fn Predicate) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.predicates[name] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook registers a hook under name.
|
||||||
|
func (r *Registry) Hook(name string, fn Hook) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.hooks[name] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) action(name string) (Action, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
a, ok := r.actions[name]
|
||||||
|
return a, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) predicate(name string) (Predicate, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
p, ok := r.predicates[name]
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) hook(name string) (Hook, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
h, ok := r.hooks[name]
|
||||||
|
return h, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities is the set of registered names, surfaced so an editor UI can
|
||||||
|
// offer exactly the actions/predicates/hooks a flow may reference.
|
||||||
|
type Capabilities struct {
|
||||||
|
Actions []string `json:"actions"`
|
||||||
|
Predicates []string `json:"predicates"`
|
||||||
|
Hooks []string `json:"hooks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities returns the sorted registered names.
|
||||||
|
func (r *Registry) Capabilities() Capabilities {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return Capabilities{
|
||||||
|
Actions: sortedKeys(r.actions),
|
||||||
|
Predicates: sortedKeys(r.predicates),
|
||||||
|
Hooks: sortedKeys(r.hooks),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys[V any](m map[string]V) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepResult records the outcome of one step.
|
||||||
|
type StepResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Skipped bool `json:"skipped,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the outcome of a whole flow run.
|
||||||
|
type Result struct {
|
||||||
|
Flow string `json:"flow"`
|
||||||
|
Steps []StepResult `json:"steps"`
|
||||||
|
Compensated []string `json:"compensated,omitempty"`
|
||||||
|
Failed bool `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine runs flow definitions against a registry.
|
||||||
|
type Engine struct {
|
||||||
|
reg *Registry
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine returns an engine bound to reg.
|
||||||
|
func NewEngine(reg *Registry, logger *slog.Logger) *Engine {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Engine{reg: reg, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks that every action, predicate and hook a definition references
|
||||||
|
// is registered. Call it at load time to fail fast on a bad flow config.
|
||||||
|
func (e *Engine) Validate(def *Definition) error {
|
||||||
|
for _, s := range def.Steps {
|
||||||
|
if _, ok := e.reg.action(s.Action); !ok {
|
||||||
|
return fmt.Errorf("flow %q step %q: unknown action %q", def.Name, s.Name, s.Action)
|
||||||
|
}
|
||||||
|
if s.When != "" {
|
||||||
|
if _, ok := e.reg.predicate(s.When); !ok {
|
||||||
|
return fmt.Errorf("flow %q step %q: unknown predicate %q", def.Name, s.Name, s.When)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Compensate != nil {
|
||||||
|
if _, ok := e.reg.action(s.Compensate.Action); !ok {
|
||||||
|
return fmt.Errorf("flow %q step %q: unknown compensate action %q", def.Name, s.Name, s.Compensate.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, hs := range [][]HookRef{s.Hooks.Before, s.Hooks.After, s.Hooks.OnError} {
|
||||||
|
for _, h := range hs {
|
||||||
|
if _, ok := e.reg.hook(h.Type); !ok {
|
||||||
|
return fmt.Errorf("flow %q step %q: unknown hook %q", def.Name, s.Name, h.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the flow. On a step error (without ContinueOnError) it fires the
|
||||||
|
// step's on-error hooks, compensates every already-executed step that declared a
|
||||||
|
// compensating action (in reverse), and returns the error. Hook failures are
|
||||||
|
// logged, never fatal.
|
||||||
|
func (e *Engine) Run(ctx context.Context, def *Definition, st *State) (*Result, error) {
|
||||||
|
res := &Result{Flow: def.Name}
|
||||||
|
var executed []Step // steps that completed, for reverse compensation
|
||||||
|
|
||||||
|
for _, step := range def.Steps {
|
||||||
|
if step.When != "" {
|
||||||
|
pred, ok := e.reg.predicate(step.When)
|
||||||
|
if !ok {
|
||||||
|
return res, e.fail(res, step, fmt.Errorf("unknown predicate %q", step.When))
|
||||||
|
}
|
||||||
|
run, err := pred(ctx, st)
|
||||||
|
if err != nil {
|
||||||
|
return res, e.abort(ctx, res, &executed, step, err, st)
|
||||||
|
}
|
||||||
|
if !run {
|
||||||
|
res.Steps = append(res.Steps, StepResult{Name: step.Name, Skipped: true})
|
||||||
|
st.Logger.Info("flow step skipped", "flow", def.Name, "step", step.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
action, ok := e.reg.action(step.Action)
|
||||||
|
if !ok {
|
||||||
|
return res, e.fail(res, step, fmt.Errorf("unknown action %q", step.Action))
|
||||||
|
}
|
||||||
|
|
||||||
|
e.fireHooks(ctx, st, step.Hooks.Before, HookInfo{Step: step.Name, Phase: PhaseBefore})
|
||||||
|
|
||||||
|
err := action(ctx, st, step.Params)
|
||||||
|
if err != nil {
|
||||||
|
e.fireHooks(ctx, st, step.Hooks.OnError, HookInfo{Step: step.Name, Phase: PhaseOnError, Err: err})
|
||||||
|
if step.ContinueOnError {
|
||||||
|
res.Steps = append(res.Steps, StepResult{Name: step.Name, Error: err.Error()})
|
||||||
|
st.Logger.Warn("flow step failed, continuing", "flow", def.Name, "step", step.Name, "err", err)
|
||||||
|
executed = append(executed, step)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return res, e.abort(ctx, res, &executed, step, err, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.fireHooks(ctx, st, step.Hooks.After, HookInfo{Step: step.Name, Phase: PhaseAfter})
|
||||||
|
res.Steps = append(res.Steps, StepResult{Name: step.Name})
|
||||||
|
executed = append(executed, step)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail records a step error without compensation (used for config errors found
|
||||||
|
// before the action ran, so nothing needs undoing for this step).
|
||||||
|
func (e *Engine) fail(res *Result, step Step, err error) error {
|
||||||
|
res.Failed = true
|
||||||
|
res.Steps = append(res.Steps, StepResult{Name: step.Name, Error: err.Error()})
|
||||||
|
return fmt.Errorf("flow %q step %q: %w", res.Flow, step.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// abort records the failing step, then compensates executed steps in reverse.
|
||||||
|
func (e *Engine) abort(ctx context.Context, res *Result, executed *[]Step, step Step, cause error, st *State) error {
|
||||||
|
res.Failed = true
|
||||||
|
res.Steps = append(res.Steps, StepResult{Name: step.Name, Error: cause.Error()})
|
||||||
|
e.compensate(ctx, res, *executed, st)
|
||||||
|
return fmt.Errorf("flow %q step %q: %w", res.Flow, step.Name, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compensate runs the compensating action of each executed step in reverse.
|
||||||
|
func (e *Engine) compensate(ctx context.Context, res *Result, executed []Step, st *State) {
|
||||||
|
for i := len(executed) - 1; i >= 0; i-- {
|
||||||
|
step := executed[i]
|
||||||
|
if step.Compensate == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
action, ok := e.reg.action(step.Compensate.Action)
|
||||||
|
if !ok {
|
||||||
|
st.Logger.Error("flow compensation skipped: unknown action",
|
||||||
|
"flow", res.Flow, "step", step.Name, "action", step.Compensate.Action)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := action(ctx, st, step.Compensate.Params); err != nil {
|
||||||
|
st.Logger.Error("flow compensation failed", "flow", res.Flow, "step", step.Name, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Compensated = append(res.Compensated, step.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireHooks runs every hook for a phase, logging (never propagating) failures.
|
||||||
|
func (e *Engine) fireHooks(ctx context.Context, st *State, refs []HookRef, info HookInfo) {
|
||||||
|
for _, ref := range refs {
|
||||||
|
h, ok := e.reg.hook(ref.Type)
|
||||||
|
if !ok {
|
||||||
|
st.Logger.Error("flow hook skipped: unknown type", "hook", ref.Type, "step", info.Step)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h(ctx, st, info, ref.Params); err != nil {
|
||||||
|
st.Logger.Warn("flow hook error", "hook", ref.Type, "step", info.Step, "phase", info.Phase, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package flow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunRecordsOrderAndHooks(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
var trace []string
|
||||||
|
reg.Action("a", func(_ context.Context, _ *State, _ json.RawMessage) error {
|
||||||
|
trace = append(trace, "a")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
reg.Action("b", func(_ context.Context, _ *State, _ json.RawMessage) error {
|
||||||
|
trace = append(trace, "b")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
reg.Hook("rec", func(_ context.Context, _ *State, info HookInfo, _ json.RawMessage) error {
|
||||||
|
trace = append(trace, string(info.Phase)+":"+info.Step)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
def := &Definition{Name: "t", Steps: []Step{
|
||||||
|
{Name: "s1", Action: "a", Hooks: Hooks{Before: []HookRef{{Type: "rec"}}, After: []HookRef{{Type: "rec"}}}},
|
||||||
|
{Name: "s2", Action: "b"},
|
||||||
|
}}
|
||||||
|
res, err := NewEngine(reg, nil).Run(context.Background(), def, NewState(1, nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Failed || len(res.Steps) != 2 {
|
||||||
|
t.Fatalf("unexpected result %+v", res)
|
||||||
|
}
|
||||||
|
want := []string{"before:s1", "a", "after:s1", "b"}
|
||||||
|
if fmt.Sprint(trace) != fmt.Sprint(want) {
|
||||||
|
t.Fatalf("trace = %v, want %v", trace, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPredicateSkipsStep(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
ran := false
|
||||||
|
reg.Action("a", func(_ context.Context, _ *State, _ json.RawMessage) error { ran = true; return nil })
|
||||||
|
reg.Predicate("never", func(_ context.Context, _ *State) (bool, error) { return false, nil })
|
||||||
|
|
||||||
|
def := &Definition{Name: "t", Steps: []Step{{Name: "s1", Action: "a", When: "never"}}}
|
||||||
|
res, err := NewEngine(reg, nil).Run(context.Background(), def, NewState(1, nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ran {
|
||||||
|
t.Fatal("action ran despite predicate=false")
|
||||||
|
}
|
||||||
|
if !res.Steps[0].Skipped {
|
||||||
|
t.Fatal("step not marked skipped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContinueOnError(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
reg.Action("boom", func(_ context.Context, _ *State, _ json.RawMessage) error { return fmt.Errorf("x") })
|
||||||
|
reg.Action("ok", func(_ context.Context, _ *State, _ json.RawMessage) error { return nil })
|
||||||
|
|
||||||
|
def := &Definition{Name: "t", Steps: []Step{
|
||||||
|
{Name: "s1", Action: "boom", ContinueOnError: true},
|
||||||
|
{Name: "s2", Action: "ok"},
|
||||||
|
}}
|
||||||
|
res, err := NewEngine(reg, nil).Run(context.Background(), def, NewState(1, nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("continue-on-error should not abort: %v", err)
|
||||||
|
}
|
||||||
|
if res.Steps[0].Error == "" || res.Steps[1].Error != "" {
|
||||||
|
t.Fatalf("unexpected step results %+v", res.Steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCatchesUnknownRefs(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
reg.Action("a", func(_ context.Context, _ *State, _ json.RawMessage) error { return nil })
|
||||||
|
eng := NewEngine(reg, nil)
|
||||||
|
|
||||||
|
if err := eng.Validate(&Definition{Name: "t", Steps: []Step{{Name: "s", Action: "missing"}}}); err == nil {
|
||||||
|
t.Fatal("expected unknown-action error")
|
||||||
|
}
|
||||||
|
if err := eng.Validate(&Definition{Name: "t", Steps: []Step{{Name: "s", Action: "a"}}}); err != nil {
|
||||||
|
t.Fatalf("valid def rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Package flow is a generic, JSON-configurable orchestration engine. A flow is a
|
||||||
|
// sequence of named steps; each step runs a registered action, may be gated by a
|
||||||
|
// registered predicate, can fire registered hooks (before/after/on-error), and
|
||||||
|
// may declare a compensating action used to roll back on failure (the saga
|
||||||
|
// pattern). Actions, predicates and hooks are registered by name — the same
|
||||||
|
// "interface + interchangeable impls, wired in one place" seam the CMS uses for
|
||||||
|
// its event listeners. The flow definition itself is plain JSON, so flows live
|
||||||
|
// alongside the app's other JSON config (schemas, blueprints) and can be edited
|
||||||
|
// without code changes.
|
||||||
|
package flow
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Definition is a whole flow, loadable from JSON.
|
||||||
|
type Definition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Steps []Step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is one unit of work in a flow.
|
||||||
|
type Step struct {
|
||||||
|
// Name identifies the step in logs, hooks and results.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Action is the registered action to run.
|
||||||
|
Action string `json:"action"`
|
||||||
|
// Params is opaque JSON passed to the action.
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
// When, if set, names a registered predicate; the step is skipped when it
|
||||||
|
// returns false.
|
||||||
|
When string `json:"when,omitempty"`
|
||||||
|
// Hooks fire around the action.
|
||||||
|
Hooks Hooks `json:"hooks,omitempty"`
|
||||||
|
// Compensate, if set, is the action used to undo this step when a later step
|
||||||
|
// fails (executed in reverse order during rollback).
|
||||||
|
Compensate *ActionRef `json:"compensate,omitempty"`
|
||||||
|
// ContinueOnError records the error and proceeds instead of rolling back.
|
||||||
|
ContinueOnError bool `json:"continueOnError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionRef names an action and its params (used for compensation).
|
||||||
|
type ActionRef struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks groups the hook references that fire at each phase of a step.
|
||||||
|
type Hooks struct {
|
||||||
|
Before []HookRef `json:"before,omitempty"`
|
||||||
|
After []HookRef `json:"after,omitempty"`
|
||||||
|
OnError []HookRef `json:"onError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookRef names a registered hook and its params.
|
||||||
|
type HookRef struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse loads a flow definition from JSON bytes.
|
||||||
|
func Parse(data []byte) (*Definition, error) {
|
||||||
|
var d Definition
|
||||||
|
if err := json.Unmarshal(data, &d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package flow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterBuiltinHooks adds the generic hooks every deployment can use: "log"
|
||||||
|
// (structured log line) and "webhook" (HTTP POST of the event). Domain packages
|
||||||
|
// register their own hooks (e.g. "amqp_emit") on the same registry.
|
||||||
|
func RegisterBuiltinHooks(reg *Registry) {
|
||||||
|
reg.Hook("log", logHook)
|
||||||
|
reg.Hook("noop", func(context.Context, *State, HookInfo, json.RawMessage) error { return nil })
|
||||||
|
reg.Hook("webhook", webhookHook(&http.Client{Timeout: 10 * time.Second}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// logHook emits a structured log line for the step/phase. Params (optional):
|
||||||
|
//
|
||||||
|
// {"message": "custom text"}
|
||||||
|
func logHook(_ context.Context, st *State, info HookInfo, params json.RawMessage) error {
|
||||||
|
msg := "flow hook"
|
||||||
|
if len(params) > 0 {
|
||||||
|
var p struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(params, &p); err == nil && p.Message != "" {
|
||||||
|
msg = p.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := []any{"step", info.Step, "phase", info.Phase, "id", st.ID}
|
||||||
|
if info.Err != nil {
|
||||||
|
args = append(args, "err", info.Err)
|
||||||
|
}
|
||||||
|
st.Logger.Info(msg, args...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookHook POSTs a JSON event to a configured URL. Params:
|
||||||
|
//
|
||||||
|
// {"url": "https://...", "headers": {"X-Token": "..."}}
|
||||||
|
//
|
||||||
|
// The body is {step, phase, id, error, vars}. Returned as a closure so the HTTP
|
||||||
|
// client (and its timeout/transport) is shared across invocations.
|
||||||
|
func webhookHook(client *http.Client) Hook {
|
||||||
|
return func(ctx context.Context, st *State, info HookInfo, params json.RawMessage) error {
|
||||||
|
var p struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(params, &p); err != nil {
|
||||||
|
return fmt.Errorf("webhook: bad params: %w", err)
|
||||||
|
}
|
||||||
|
if p.URL == "" {
|
||||||
|
return fmt.Errorf("webhook: missing url")
|
||||||
|
}
|
||||||
|
errStr := ""
|
||||||
|
if info.Err != nil {
|
||||||
|
errStr = info.Err.Error()
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"step": info.Step,
|
||||||
|
"phase": info.Phase,
|
||||||
|
"id": st.ID,
|
||||||
|
"error": errStr,
|
||||||
|
"vars": st.Vars,
|
||||||
|
})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.URL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
for k, v := range p.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("webhook: %s returned %d", p.URL, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
package order_messages;
|
||||||
|
option go_package = "git.k6n.net/mats/go-cart-actor/proto/order;order_messages";
|
||||||
|
|
||||||
|
// Order mutations. Each message is one immutable event in an order's history.
|
||||||
|
// The grain's event log (actor.DiskStorage) is the source of truth; the current
|
||||||
|
// OrderGrain state is a projection folded by replaying these in order. The
|
||||||
|
// order state machine (pkg/order) guards which mutation is legal in which state.
|
||||||
|
//
|
||||||
|
// Timestamps are carried as unix-millis fields set by the caller so that replay
|
||||||
|
// is deterministic (the registry does not pass the event timestamp to handlers).
|
||||||
|
|
||||||
|
message OrderLine {
|
||||||
|
string reference = 1; // stable line reference (cart line id / sku)
|
||||||
|
string sku = 2;
|
||||||
|
string name = 3;
|
||||||
|
int32 quantity = 4;
|
||||||
|
int64 unit_price = 5; // inc-vat, minor units
|
||||||
|
int32 tax_rate = 6; // percent
|
||||||
|
int64 total_amount = 7;
|
||||||
|
int64 total_tax = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaceOrder creates the order (only legal when the order does not yet exist).
|
||||||
|
message PlaceOrder {
|
||||||
|
string order_reference = 1; // external reference (e.g. checkout/cart id)
|
||||||
|
string cart_id = 2;
|
||||||
|
string currency = 3;
|
||||||
|
string locale = 4;
|
||||||
|
string country = 5;
|
||||||
|
int64 total_amount = 6; // inc-vat, minor units
|
||||||
|
int64 total_tax = 7;
|
||||||
|
repeated OrderLine lines = 8;
|
||||||
|
string customer_email = 9;
|
||||||
|
string customer_name = 10;
|
||||||
|
bytes billing_address = 11; // raw JSON, stored losslessly
|
||||||
|
bytes shipping_address = 12; // raw JSON, stored losslessly
|
||||||
|
int64 placed_at_ms = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizePayment records a successful authorization against a provider.
|
||||||
|
message AuthorizePayment {
|
||||||
|
string provider = 1;
|
||||||
|
int64 amount = 2;
|
||||||
|
string reference = 3; // processor authorization reference
|
||||||
|
int64 at_ms = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapturePayment records a (possibly partial) capture against an authorization.
|
||||||
|
message CapturePayment {
|
||||||
|
string provider = 1;
|
||||||
|
int64 amount = 2;
|
||||||
|
string reference = 3; // processor capture reference
|
||||||
|
int64 at_ms = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FulfillmentLine {
|
||||||
|
string reference = 1;
|
||||||
|
int32 quantity = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFulfillment ships some or all of the order's lines.
|
||||||
|
message CreateFulfillment {
|
||||||
|
string id = 1;
|
||||||
|
string carrier = 2;
|
||||||
|
string tracking_number = 3;
|
||||||
|
string tracking_uri = 4;
|
||||||
|
repeated FulfillmentLine lines = 5;
|
||||||
|
int64 at_ms = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteOrder marks a fully-fulfilled order complete.
|
||||||
|
message CompleteOrder {
|
||||||
|
int64 at_ms = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelOrder cancels an order that has not yet been captured.
|
||||||
|
message CancelOrder {
|
||||||
|
string reason = 1;
|
||||||
|
int64 at_ms = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestReturn opens an RMA against fulfilled lines (does not change status).
|
||||||
|
message RequestReturn {
|
||||||
|
string id = 1;
|
||||||
|
string reason = 2;
|
||||||
|
repeated FulfillmentLine lines = 3;
|
||||||
|
int64 at_ms = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueRefund records a (possibly partial) refund against a captured payment.
|
||||||
|
message IssueRefund {
|
||||||
|
string provider = 1;
|
||||||
|
int64 amount = 2;
|
||||||
|
string reference = 3; // processor refund reference
|
||||||
|
string return_id = 4; // optional linked RMA
|
||||||
|
int64 at_ms = 5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,955 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.5
|
||||||
|
// protoc v7.35.0
|
||||||
|
// source: order.proto
|
||||||
|
|
||||||
|
package order_messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderLine struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Reference string `protobuf:"bytes,1,opt,name=reference,proto3" json:"reference,omitempty"` // stable line reference (cart line id / sku)
|
||||||
|
Sku string `protobuf:"bytes,2,opt,name=sku,proto3" json:"sku,omitempty"`
|
||||||
|
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
|
||||||
|
Quantity int32 `protobuf:"varint,4,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||||
|
UnitPrice int64 `protobuf:"varint,5,opt,name=unit_price,json=unitPrice,proto3" json:"unit_price,omitempty"` // inc-vat, minor units
|
||||||
|
TaxRate int32 `protobuf:"varint,6,opt,name=tax_rate,json=taxRate,proto3" json:"tax_rate,omitempty"` // percent
|
||||||
|
TotalAmount int64 `protobuf:"varint,7,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"`
|
||||||
|
TotalTax int64 `protobuf:"varint,8,opt,name=total_tax,json=totalTax,proto3" json:"total_tax,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) Reset() {
|
||||||
|
*x = OrderLine{}
|
||||||
|
mi := &file_order_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*OrderLine) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *OrderLine) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use OrderLine.ProtoReflect.Descriptor instead.
|
||||||
|
func (*OrderLine) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) GetReference() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reference
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) GetSku() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Sku
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) GetName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) GetQuantity() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Quantity
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) GetUnitPrice() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.UnitPrice
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) GetTaxRate() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TaxRate
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) GetTotalAmount() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TotalAmount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OrderLine) GetTotalTax() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TotalTax
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaceOrder creates the order (only legal when the order does not yet exist).
|
||||||
|
type PlaceOrder struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
OrderReference string `protobuf:"bytes,1,opt,name=order_reference,json=orderReference,proto3" json:"order_reference,omitempty"` // external reference (e.g. checkout/cart id)
|
||||||
|
CartId string `protobuf:"bytes,2,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"`
|
||||||
|
Currency string `protobuf:"bytes,3,opt,name=currency,proto3" json:"currency,omitempty"`
|
||||||
|
Locale string `protobuf:"bytes,4,opt,name=locale,proto3" json:"locale,omitempty"`
|
||||||
|
Country string `protobuf:"bytes,5,opt,name=country,proto3" json:"country,omitempty"`
|
||||||
|
TotalAmount int64 `protobuf:"varint,6,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"` // inc-vat, minor units
|
||||||
|
TotalTax int64 `protobuf:"varint,7,opt,name=total_tax,json=totalTax,proto3" json:"total_tax,omitempty"`
|
||||||
|
Lines []*OrderLine `protobuf:"bytes,8,rep,name=lines,proto3" json:"lines,omitempty"`
|
||||||
|
CustomerEmail string `protobuf:"bytes,9,opt,name=customer_email,json=customerEmail,proto3" json:"customer_email,omitempty"`
|
||||||
|
CustomerName string `protobuf:"bytes,10,opt,name=customer_name,json=customerName,proto3" json:"customer_name,omitempty"`
|
||||||
|
BillingAddress []byte `protobuf:"bytes,11,opt,name=billing_address,json=billingAddress,proto3" json:"billing_address,omitempty"` // raw JSON, stored losslessly
|
||||||
|
ShippingAddress []byte `protobuf:"bytes,12,opt,name=shipping_address,json=shippingAddress,proto3" json:"shipping_address,omitempty"` // raw JSON, stored losslessly
|
||||||
|
PlacedAtMs int64 `protobuf:"varint,13,opt,name=placed_at_ms,json=placedAtMs,proto3" json:"placed_at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) Reset() {
|
||||||
|
*x = PlaceOrder{}
|
||||||
|
mi := &file_order_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*PlaceOrder) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use PlaceOrder.ProtoReflect.Descriptor instead.
|
||||||
|
func (*PlaceOrder) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetOrderReference() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.OrderReference
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetCartId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.CartId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetCurrency() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Currency
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetLocale() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Locale
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetCountry() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Country
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetTotalAmount() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TotalAmount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetTotalTax() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TotalTax
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetLines() []*OrderLine {
|
||||||
|
if x != nil {
|
||||||
|
return x.Lines
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetCustomerEmail() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.CustomerEmail
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetCustomerName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.CustomerName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetBillingAddress() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.BillingAddress
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetShippingAddress() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.ShippingAddress
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PlaceOrder) GetPlacedAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PlacedAtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizePayment records a successful authorization against a provider.
|
||||||
|
type AuthorizePayment struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||||
|
Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||||
|
Reference string `protobuf:"bytes,3,opt,name=reference,proto3" json:"reference,omitempty"` // processor authorization reference
|
||||||
|
AtMs int64 `protobuf:"varint,4,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AuthorizePayment) Reset() {
|
||||||
|
*x = AuthorizePayment{}
|
||||||
|
mi := &file_order_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AuthorizePayment) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AuthorizePayment) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *AuthorizePayment) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use AuthorizePayment.ProtoReflect.Descriptor instead.
|
||||||
|
func (*AuthorizePayment) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AuthorizePayment) GetProvider() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Provider
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AuthorizePayment) GetAmount() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Amount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AuthorizePayment) GetReference() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reference
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AuthorizePayment) GetAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapturePayment records a (possibly partial) capture against an authorization.
|
||||||
|
type CapturePayment struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||||
|
Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||||
|
Reference string `protobuf:"bytes,3,opt,name=reference,proto3" json:"reference,omitempty"` // processor capture reference
|
||||||
|
AtMs int64 `protobuf:"varint,4,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CapturePayment) Reset() {
|
||||||
|
*x = CapturePayment{}
|
||||||
|
mi := &file_order_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CapturePayment) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CapturePayment) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CapturePayment) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CapturePayment.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CapturePayment) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CapturePayment) GetProvider() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Provider
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CapturePayment) GetAmount() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Amount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CapturePayment) GetReference() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reference
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CapturePayment) GetAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type FulfillmentLine struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Reference string `protobuf:"bytes,1,opt,name=reference,proto3" json:"reference,omitempty"`
|
||||||
|
Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FulfillmentLine) Reset() {
|
||||||
|
*x = FulfillmentLine{}
|
||||||
|
mi := &file_order_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FulfillmentLine) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*FulfillmentLine) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *FulfillmentLine) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use FulfillmentLine.ProtoReflect.Descriptor instead.
|
||||||
|
func (*FulfillmentLine) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FulfillmentLine) GetReference() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reference
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *FulfillmentLine) GetQuantity() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Quantity
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFulfillment ships some or all of the order's lines.
|
||||||
|
type CreateFulfillment struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
Carrier string `protobuf:"bytes,2,opt,name=carrier,proto3" json:"carrier,omitempty"`
|
||||||
|
TrackingNumber string `protobuf:"bytes,3,opt,name=tracking_number,json=trackingNumber,proto3" json:"tracking_number,omitempty"`
|
||||||
|
TrackingUri string `protobuf:"bytes,4,opt,name=tracking_uri,json=trackingUri,proto3" json:"tracking_uri,omitempty"`
|
||||||
|
Lines []*FulfillmentLine `protobuf:"bytes,5,rep,name=lines,proto3" json:"lines,omitempty"`
|
||||||
|
AtMs int64 `protobuf:"varint,6,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreateFulfillment) Reset() {
|
||||||
|
*x = CreateFulfillment{}
|
||||||
|
mi := &file_order_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreateFulfillment) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CreateFulfillment) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CreateFulfillment) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CreateFulfillment.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CreateFulfillment) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreateFulfillment) GetId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreateFulfillment) GetCarrier() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Carrier
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreateFulfillment) GetTrackingNumber() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.TrackingNumber
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreateFulfillment) GetTrackingUri() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.TrackingUri
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreateFulfillment) GetLines() []*FulfillmentLine {
|
||||||
|
if x != nil {
|
||||||
|
return x.Lines
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CreateFulfillment) GetAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteOrder marks a fully-fulfilled order complete.
|
||||||
|
type CompleteOrder struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
AtMs int64 `protobuf:"varint,1,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CompleteOrder) Reset() {
|
||||||
|
*x = CompleteOrder{}
|
||||||
|
mi := &file_order_proto_msgTypes[6]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CompleteOrder) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CompleteOrder) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CompleteOrder) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[6]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CompleteOrder.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CompleteOrder) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{6}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CompleteOrder) GetAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelOrder cancels an order that has not yet been captured.
|
||||||
|
type CancelOrder struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||||
|
AtMs int64 `protobuf:"varint,2,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CancelOrder) Reset() {
|
||||||
|
*x = CancelOrder{}
|
||||||
|
mi := &file_order_proto_msgTypes[7]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CancelOrder) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CancelOrder) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CancelOrder) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[7]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CancelOrder.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CancelOrder) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{7}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CancelOrder) GetReason() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reason
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CancelOrder) GetAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestReturn opens an RMA against fulfilled lines (does not change status).
|
||||||
|
type RequestReturn struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||||
|
Lines []*FulfillmentLine `protobuf:"bytes,3,rep,name=lines,proto3" json:"lines,omitempty"`
|
||||||
|
AtMs int64 `protobuf:"varint,4,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestReturn) Reset() {
|
||||||
|
*x = RequestReturn{}
|
||||||
|
mi := &file_order_proto_msgTypes[8]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestReturn) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*RequestReturn) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *RequestReturn) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[8]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use RequestReturn.ProtoReflect.Descriptor instead.
|
||||||
|
func (*RequestReturn) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{8}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestReturn) GetId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestReturn) GetReason() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reason
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestReturn) GetLines() []*FulfillmentLine {
|
||||||
|
if x != nil {
|
||||||
|
return x.Lines
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestReturn) GetAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueRefund records a (possibly partial) refund against a captured payment.
|
||||||
|
type IssueRefund struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||||
|
Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||||
|
Reference string `protobuf:"bytes,3,opt,name=reference,proto3" json:"reference,omitempty"` // processor refund reference
|
||||||
|
ReturnId string `protobuf:"bytes,4,opt,name=return_id,json=returnId,proto3" json:"return_id,omitempty"` // optional linked RMA
|
||||||
|
AtMs int64 `protobuf:"varint,5,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssueRefund) Reset() {
|
||||||
|
*x = IssueRefund{}
|
||||||
|
mi := &file_order_proto_msgTypes[9]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssueRefund) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*IssueRefund) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *IssueRefund) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[9]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use IssueRefund.ProtoReflect.Descriptor instead.
|
||||||
|
func (*IssueRefund) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{9}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssueRefund) GetProvider() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Provider
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssueRefund) GetAmount() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Amount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssueRefund) GetReference() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reference
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssueRefund) GetReturnId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ReturnId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssueRefund) GetAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_order_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_order_proto_rawDesc = string([]byte{
|
||||||
|
0x0a, 0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x6f,
|
||||||
|
0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xe5, 0x01,
|
||||||
|
0x0a, 0x09, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72,
|
||||||
|
0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
|
||||||
|
0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75,
|
||||||
|
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x6b, 0x75, 0x12, 0x12, 0x0a, 0x04, 0x6e,
|
||||||
|
0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12,
|
||||||
|
0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||||
|
0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x75,
|
||||||
|
0x6e, 0x69, 0x74, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52,
|
||||||
|
0x09, 0x75, 0x6e, 0x69, 0x74, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x61,
|
||||||
|
0x78, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x74, 0x61,
|
||||||
|
0x78, 0x52, 0x61, 0x74, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61,
|
||||||
|
0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74,
|
||||||
|
0x61, 0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61,
|
||||||
|
0x6c, 0x5f, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74,
|
||||||
|
0x61, 0x6c, 0x54, 0x61, 0x78, 0x22, 0xcf, 0x03, 0x0a, 0x0a, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x4f,
|
||||||
|
0x72, 0x64, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x72, 0x65,
|
||||||
|
0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f,
|
||||||
|
0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x17, 0x0a,
|
||||||
|
0x07, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
|
||||||
|
0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
|
||||||
|
0x63, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
|
||||||
|
0x63, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01,
|
||||||
|
0x28, 0x09, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f,
|
||||||
|
0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75,
|
||||||
|
0x6e, 0x74, 0x72, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d,
|
||||||
|
0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61,
|
||||||
|
0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c,
|
||||||
|
0x5f, 0x74, 0x61, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, 0x61,
|
||||||
|
0x6c, 0x54, 0x61, 0x78, 0x12, 0x2f, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x08, 0x20,
|
||||||
|
0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||||
|
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05,
|
||||||
|
0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65,
|
||||||
|
0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63,
|
||||||
|
0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d,
|
||||||
|
0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20,
|
||||||
|
0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x4e, 0x61, 0x6d,
|
||||||
|
0x65, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64,
|
||||||
|
0x72, 0x65, 0x73, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, 0x6c,
|
||||||
|
0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68,
|
||||||
|
0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0c,
|
||||||
|
0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64,
|
||||||
|
0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x5f,
|
||||||
|
0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x6c, 0x61,
|
||||||
|
0x63, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0x79, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f,
|
||||||
|
0x72, 0x69, 0x7a, 0x65, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70,
|
||||||
|
0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70,
|
||||||
|
0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e,
|
||||||
|
0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12,
|
||||||
|
0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01,
|
||||||
|
0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a,
|
||||||
|
0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74,
|
||||||
|
0x4d, 0x73, 0x22, 0x77, 0x0a, 0x0e, 0x43, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x50, 0x61, 0x79,
|
||||||
|
0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
|
||||||
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
|
||||||
|
0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
|
||||||
|
0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65,
|
||||||
|
0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66,
|
||||||
|
0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
|
||||||
|
0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x4b, 0x0a, 0x0f, 0x46,
|
||||||
|
0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c,
|
||||||
|
0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
|
0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08,
|
||||||
|
0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08,
|
||||||
|
0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xd5, 0x01, 0x0a, 0x11, 0x43, 0x72, 0x65,
|
||||||
|
0x61, 0x74, 0x65, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x0e,
|
||||||
|
0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18,
|
||||||
|
0x0a, 0x07, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
|
0x07, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x72, 0x61, 0x63,
|
||||||
|
0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||||
|
0x09, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x4e, 0x75, 0x6d, 0x62, 0x65,
|
||||||
|
0x72, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x72,
|
||||||
|
0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e,
|
||||||
|
0x67, 0x55, 0x72, 0x69, 0x12, 0x35, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20,
|
||||||
|
0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||||
|
0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74,
|
||||||
|
0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61,
|
||||||
|
0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73,
|
||||||
|
0x22, 0x24, 0x0a, 0x0d, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x72, 0x64, 0x65,
|
||||||
|
0x72, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
|
||||||
|
0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x3a, 0x0a, 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c,
|
||||||
|
0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18,
|
||||||
|
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x13, 0x0a,
|
||||||
|
0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74,
|
||||||
|
0x4d, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65,
|
||||||
|
0x74, 0x75, 0x72, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||||
|
0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02,
|
||||||
|
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x05,
|
||||||
|
0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72,
|
||||||
|
0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c,
|
||||||
|
0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69,
|
||||||
|
0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01,
|
||||||
|
0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x91, 0x01, 0x0a, 0x0b, 0x49, 0x73, 0x73,
|
||||||
|
0x75, 0x65, 0x52, 0x65, 0x66, 0x75, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76,
|
||||||
|
0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76,
|
||||||
|
0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02,
|
||||||
|
0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09,
|
||||||
|
0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
|
0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65,
|
||||||
|
0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72,
|
||||||
|
0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73,
|
||||||
|
0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39,
|
||||||
|
0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73,
|
||||||
|
0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70,
|
||||||
|
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72,
|
||||||
|
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||||
|
0x33,
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_order_proto_rawDescOnce sync.Once
|
||||||
|
file_order_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_order_proto_rawDescGZIP() []byte {
|
||||||
|
file_order_proto_rawDescOnce.Do(func() {
|
||||||
|
file_order_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_order_proto_rawDesc), len(file_order_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_order_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_order_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
||||||
|
var file_order_proto_goTypes = []any{
|
||||||
|
(*OrderLine)(nil), // 0: order_messages.OrderLine
|
||||||
|
(*PlaceOrder)(nil), // 1: order_messages.PlaceOrder
|
||||||
|
(*AuthorizePayment)(nil), // 2: order_messages.AuthorizePayment
|
||||||
|
(*CapturePayment)(nil), // 3: order_messages.CapturePayment
|
||||||
|
(*FulfillmentLine)(nil), // 4: order_messages.FulfillmentLine
|
||||||
|
(*CreateFulfillment)(nil), // 5: order_messages.CreateFulfillment
|
||||||
|
(*CompleteOrder)(nil), // 6: order_messages.CompleteOrder
|
||||||
|
(*CancelOrder)(nil), // 7: order_messages.CancelOrder
|
||||||
|
(*RequestReturn)(nil), // 8: order_messages.RequestReturn
|
||||||
|
(*IssueRefund)(nil), // 9: order_messages.IssueRefund
|
||||||
|
}
|
||||||
|
var file_order_proto_depIdxs = []int32{
|
||||||
|
0, // 0: order_messages.PlaceOrder.lines:type_name -> order_messages.OrderLine
|
||||||
|
4, // 1: order_messages.CreateFulfillment.lines:type_name -> order_messages.FulfillmentLine
|
||||||
|
4, // 2: order_messages.RequestReturn.lines:type_name -> order_messages.FulfillmentLine
|
||||||
|
3, // [3:3] is the sub-list for method output_type
|
||||||
|
3, // [3:3] is the sub-list for method input_type
|
||||||
|
3, // [3:3] is the sub-list for extension type_name
|
||||||
|
3, // [3:3] is the sub-list for extension extendee
|
||||||
|
0, // [0:3] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_order_proto_init() }
|
||||||
|
func file_order_proto_init() {
|
||||||
|
if File_order_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_order_proto_rawDesc), len(file_order_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 10,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 0,
|
||||||
|
},
|
||||||
|
GoTypes: file_order_proto_goTypes,
|
||||||
|
DependencyIndexes: file_order_proto_depIdxs,
|
||||||
|
MessageInfos: file_order_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_order_proto = out.File
|
||||||
|
file_order_proto_goTypes = nil
|
||||||
|
file_order_proto_depIdxs = nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user