order sagas
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-21 11:33:20 +02:00
parent 484f2364fb
commit 1a365de071
25 changed files with 4224 additions and 1 deletions
+231
View File
@@ -0,0 +1,231 @@
package order
import (
"context"
"embed"
"encoding/json"
"fmt"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"google.golang.org/protobuf/proto"
)
//go:embed flows/*.json
var flowFS embed.FS
// EmbeddedFlow returns a built-in flow definition by name (e.g. "place-and-pay").
// Deployments can ship their own JSON instead; this is the default.
func EmbeddedFlow(name string) (*flow.Definition, error) {
data, err := flowFS.ReadFile("flows/" + name + ".json")
if err != nil {
return nil, fmt.Errorf("order: no embedded flow %q: %w", name, err)
}
return flow.Parse(data)
}
// Applier is the slice of the grain pool the order flow actions need. The
// SimpleGrainPool[OrderGrain] satisfies it directly.
type Applier interface {
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[OrderGrain], error)
Get(ctx context.Context, id uint64) (*OrderGrain, error)
}
func nowMs() int64 { return time.Now().UnixMilli() }
// applyOne applies a single mutation and surfaces the handler error. The grain
// pool only returns a top-level error for *unregistered* mutations; a handler
// error (e.g. an illegal state transition) is carried per-mutation in the
// result, so a flow action must inspect it or a rejected event looks like a
// success and the flow proceeds wrongly.
func applyOne(ctx context.Context, app Applier, id uint64, msg proto.Message) error {
res, err := app.Apply(ctx, id, msg)
if err != nil {
return err
}
if res != nil {
for _, m := range res.Mutations {
if m.Error != nil {
return m.Error
}
}
}
return nil
}
// PlaceOrderVar is the key under which the caller stores the *messages.PlaceOrder
// (built from the cart/checkout) in the flow State before running a flow. The
// flow config controls sequence + hooks; the order data flows in via State.
const PlaceOrderVar = "placeOrder"
// RegisterFlowActions registers the order lifecycle actions on reg, driving the
// grain through app and taking payment via provider. Compose with
// flow.RegisterBuiltinHooks(reg) to get the "log"/"webhook" hooks too.
func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvider) {
reg.Action("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder)
if !ok || po == nil {
return fmt.Errorf("place_order: flow var %q is not a *PlaceOrder", PlaceOrderVar)
}
if po.PlacedAtMs == 0 {
po.PlacedAtMs = nowMs()
}
if err := applyOne(ctx, app, st.ID, po); err != nil {
return err
}
st.Vars["currency"] = po.GetCurrency()
st.Vars["orderReference"] = po.GetOrderReference()
return nil
})
reg.Action("authorize_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
o, err := app.Get(ctx, st.ID)
if err != nil {
return err
}
auth, err := provider.Authorize(ctx, o.OrderReference, o.TotalAmount, o.Currency)
if err != nil {
return err
}
if err := applyOne(ctx, app, st.ID, &messages.AuthorizePayment{
Provider: auth.Provider,
Amount: auth.Amount,
Reference: auth.Reference,
AtMs: nowMs(),
}); err != nil {
return err
}
st.Vars["authRef"] = auth.Reference
st.Vars["authProvider"] = auth.Provider
st.Vars["authAmount"] = auth.Amount
return nil
})
reg.Action("capture_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
authRef, _ := st.Vars["authRef"].(string)
amount, _ := st.Vars["authAmount"].(int64)
if amount == 0 {
if o, err := app.Get(ctx, st.ID); err == nil {
amount = o.TotalAmount
}
}
capture, err := provider.Capture(ctx, authRef, amount)
if err != nil {
return err
}
return applyOne(ctx, app, st.ID, &messages.CapturePayment{
Provider: capture.Provider,
Amount: capture.Amount,
Reference: capture.Reference,
AtMs: nowMs(),
})
})
// void_payment is the compensation for authorize_payment: void with the
// provider and cancel the order if it is still in a cancellable state.
reg.Action("void_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
if authRef, ok := st.Vars["authRef"].(string); ok && authRef != "" {
if err := provider.Void(ctx, authRef); err != nil {
return err
}
}
// Best-effort cancel; ignore if no longer cancellable.
_, _ = app.Apply(ctx, st.ID, &messages.CancelOrder{Reason: "compensation: payment voided", AtMs: nowMs()})
return nil
})
reg.Action("cancel_order", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
reason := "cancelled"
if len(params) > 0 {
var p struct {
Reason string `json:"reason"`
}
if json.Unmarshal(params, &p) == nil && p.Reason != "" {
reason = p.Reason
}
}
return applyOne(ctx, app, st.ID, &messages.CancelOrder{Reason: reason, AtMs: nowMs()})
})
reg.Action("issue_refund", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
o, err := app.Get(ctx, st.ID)
if err != nil {
return err
}
amount := o.CapturedAmount - o.RefundedAmount // default: full remaining
if len(params) > 0 {
var p struct {
Amount int64 `json:"amount"`
}
if json.Unmarshal(params, &p) == nil && p.Amount > 0 {
amount = p.Amount
}
}
captureRef := ""
for _, p := range o.Payments {
if p.Captured > 0 && p.CaptureRef != "" {
captureRef = p.CaptureRef
break
}
}
ref, err := provider.Refund(ctx, captureRef, amount)
if err != nil {
return err
}
return applyOne(ctx, app, st.ID, &messages.IssueRefund{
Provider: ref.Provider,
Amount: ref.Amount,
Reference: ref.Reference,
AtMs: nowMs(),
})
})
registerPredicates(reg, app)
}
// registerPredicates adds the order gating predicates a step can reference via
// its "when". They read the current order grain, so they reflect the live state
// at the moment the step is reached (e.g. only fire a receipt hook once the
// order is captured). Predicates take no params (the engine's When is a bare
// name), so each is a fixed, composable boolean.
func registerPredicates(reg *flow.Registry, app Applier) {
get := func(ctx context.Context, st *flow.State) (*OrderGrain, error) { return app.Get(ctx, st.ID) }
reg.Predicate("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return o.CustomerEmail != "", nil
})
reg.Predicate("has_items", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return len(o.Lines) > 0, nil
})
reg.Predicate("is_captured", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return o.Status == StatusCaptured, nil
})
reg.Predicate("not_captured", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return o.Status != StatusCaptured, nil
})
reg.Predicate("has_open_balance", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return o.CapturedAmount-o.RefundedAmount > 0, nil
})
}
+96
View File
@@ -0,0 +1,96 @@
package order
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
)
func fullRegistry(t *testing.T, pub Publisher) *flow.Registry {
t.Helper()
reg := flow.NewRegistry()
flow.RegisterBuiltinHooks(reg)
RegisterFlowActions(reg, newPool(t), NewMockProvider())
RegisterEmitHook(reg, pub)
return reg
}
func TestCapabilitiesIncludesPredicatesAndEmit(t *testing.T) {
caps := fullRegistry(t, nil).Capabilities()
for _, want := range []string{"has_customer_email", "has_items", "is_captured", "not_captured", "has_open_balance"} {
if !slices.Contains(caps.Predicates, want) {
t.Errorf("predicates missing %q (got %v)", want, caps.Predicates)
}
}
if !slices.Contains(caps.Hooks, "amqp_emit") {
t.Errorf("hooks missing amqp_emit (got %v)", caps.Hooks)
}
}
func TestIsCapturedPredicateGatesStep(t *testing.T) {
pool := newPool(t)
reg := flow.NewRegistry()
flow.RegisterBuiltinHooks(reg)
RegisterFlowActions(reg, pool, NewMockProvider())
eng := flow.NewEngine(reg, nil)
ran := false
reg.Action("mark", func(_ context.Context, _ *flow.State, _ json.RawMessage) error { ran = true; return nil })
// Capture order 9001 via place-and-pay.
def, _ := EmbeddedFlow("place-and-pay")
paid := flow.NewState(9001, nil)
paid.Vars[PlaceOrderVar] = placeMsg()
if _, err := eng.Run(context.Background(), def, paid); err != nil {
t.Fatal(err)
}
gated := &flow.Definition{Name: "g", Steps: []flow.Step{{Name: "m", Action: "mark", When: "is_captured"}}}
if _, err := eng.Run(context.Background(), gated, flow.NewState(9001, nil)); err != nil {
t.Fatal(err)
}
if !ran {
t.Fatal("is_captured should pass for a captured order")
}
// Fresh (unplaced) order → is_captured false → step skipped.
ran = false
if _, err := eng.Run(context.Background(), gated, flow.NewState(9999, nil)); err != nil {
t.Fatal(err)
}
if ran {
t.Fatal("is_captured should skip for a new order")
}
}
type fakePublisher struct{ msgs []string }
func (f *fakePublisher) Publish(_, routingKey string, body []byte) error {
f.msgs = append(f.msgs, routingKey+":"+string(body))
return nil
}
func TestAmqpEmitHookPublishes(t *testing.T) {
fp := &fakePublisher{}
reg := flow.NewRegistry()
RegisterEmitHook(reg, fp)
reg.Action("noopact", func(_ context.Context, _ *flow.State, _ json.RawMessage) error { return nil })
eng := flow.NewEngine(reg, nil)
def := &flow.Definition{Name: "e", Steps: []flow.Step{{
Name: "s",
Action: "noopact",
Hooks: flow.Hooks{After: []flow.HookRef{{Type: "amqp_emit", Params: json.RawMessage(`{"routingKey":"k"}`)}}},
}}}
if _, err := eng.Run(context.Background(), def, flow.NewState(7, nil)); err != nil {
t.Fatal(err)
}
if len(fp.msgs) != 1 || !strings.HasPrefix(fp.msgs[0], "k:") {
t.Fatalf("expected one message routed to k, got %v", fp.msgs)
}
}
+122
View File
@@ -0,0 +1,122 @@
package order
import (
"context"
"fmt"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
)
// newPool builds a single-node order grain pool backed by a disk event log in a
// temp dir — the same machinery production uses, minus clustering.
func newPool(t *testing.T) *actor.SimpleGrainPool[OrderGrain] {
t.Helper()
reg := actor.NewMutationRegistry()
RegisterMutations(reg)
storage := actor.NewDiskStorage[OrderGrain](t.TempDir(), reg)
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[OrderGrain]{
Hostname: "test",
Spawn: func(_ context.Context, id uint64) (actor.Grain[OrderGrain], error) {
return NewOrderGrain(id, time.Now()), nil
},
SpawnHost: func(string) (actor.Host[OrderGrain], error) { return nil, fmt.Errorf("no remotes") },
Destroy: func(actor.Grain[OrderGrain]) error { return nil },
TTL: time.Hour,
PoolSize: 100,
MutationRegistry: reg,
Storage: storage,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
return pool
}
func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
reg := flow.NewRegistry()
flow.RegisterBuiltinHooks(reg)
RegisterFlowActions(reg, pool, provider)
return reg
}
func TestPlaceAndPayFlow(t *testing.T) {
pool := newPool(t)
eng := flow.NewEngine(newFlowRegistry(pool, NewMockProvider()), nil)
def, err := EmbeddedFlow("place-and-pay")
if err != nil {
t.Fatalf("load flow: %v", err)
}
if err := eng.Validate(def); err != nil {
t.Fatalf("validate: %v", err)
}
const id = 1001
st := flow.NewState(id, nil)
st.Vars[PlaceOrderVar] = placeMsg()
res, err := eng.Run(context.Background(), def, st)
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Failed {
t.Fatalf("flow reported failure: %+v", res.Steps)
}
o, err := pool.Get(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if o.Status != StatusCaptured {
t.Fatalf("status = %q, want captured", o.Status)
}
if o.CapturedAmount != 25000 {
t.Fatalf("captured = %d, want 25000", o.CapturedAmount)
}
}
// failCapture authorizes fine but fails to capture, exercising the saga
// compensation path (void_payment + cancel).
type failCapture struct{ *MockProvider }
func (failCapture) Capture(context.Context, string, int64) (Capture, error) {
return Capture{}, fmt.Errorf("processor declined capture")
}
func TestCaptureFailureCompensates(t *testing.T) {
pool := newPool(t)
eng := flow.NewEngine(newFlowRegistry(pool, failCapture{NewMockProvider()}), nil)
def, err := EmbeddedFlow("place-and-pay")
if err != nil {
t.Fatal(err)
}
const id = 1002
st := flow.NewState(id, nil)
st.Vars[PlaceOrderVar] = placeMsg()
res, err := eng.Run(context.Background(), def, st)
if err == nil {
t.Fatal("expected flow to fail on capture")
}
if !res.Failed {
t.Fatal("result should be marked failed")
}
if len(res.Compensated) != 1 || res.Compensated[0] != "authorize" {
t.Fatalf("compensated = %v, want [authorize]", res.Compensated)
}
o, err := pool.Get(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if o.Status != StatusCancelled {
t.Fatalf("status = %q, want cancelled (compensated)", o.Status)
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "place-and-pay",
"description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails.",
"steps": [
{
"name": "place",
"action": "place_order",
"hooks": {
"after": [{ "type": "log", "params": { "message": "order placed" } }]
}
},
{
"name": "authorize",
"action": "authorize_payment",
"compensate": { "action": "void_payment" },
"hooks": {
"after": [{ "type": "log", "params": { "message": "payment authorized" } }],
"onError": [{ "type": "log", "params": { "message": "authorization failed" } }]
}
},
{
"name": "capture",
"action": "capture_payment",
"hooks": {
"after": [{ "type": "log", "params": { "message": "payment captured" } }]
}
}
]
}
+58
View File
@@ -0,0 +1,58 @@
package order
import (
"context"
"encoding/json"
"fmt"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
)
// Publisher is the minimal seam the amqp_emit hook needs. cmd/order supplies a
// RabbitMQ-backed implementation; tests pass a fake. Keeping it an interface
// keeps pkg/order free of an AMQP dependency.
type Publisher interface {
Publish(exchange, routingKey string, body []byte) error
}
// RegisterEmitHook registers the "amqp_emit" flow hook, which publishes a flow
// event to a message broker — the bridge from a saga step to other services
// (loyalty, ERP sync, notifications) without coupling the flow to them. It is
// the commerce analogue of the CMS event listeners. Params (optional):
//
// {"exchange": "", "routingKey": "order-events"}
//
// The body is {step, phase, id, error, vars}. With a nil publisher the hook is
// still registered (so the editor lists it) but errors at run time — and since
// hook errors never abort a flow, that failure is logged, not fatal.
func RegisterEmitHook(reg *flow.Registry, pub Publisher) {
reg.Hook("amqp_emit", func(_ context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
if pub == nil {
return fmt.Errorf("amqp_emit: no publisher configured")
}
var p struct {
Exchange string `json:"exchange"`
RoutingKey string `json:"routingKey"`
}
if len(params) > 0 {
if err := json.Unmarshal(params, &p); err != nil {
return fmt.Errorf("amqp_emit: bad params: %w", err)
}
}
if p.RoutingKey == "" {
p.RoutingKey = "order-events"
}
errStr := ""
if info.Err != nil {
errStr = info.Err.Error()
}
body, _ := json.Marshal(map[string]any{
"step": info.Step,
"phase": info.Phase,
"id": st.ID,
"error": errStr,
"vars": st.Vars,
})
return pub.Publish(p.Exchange, p.RoutingKey, body)
})
}
+71
View File
@@ -0,0 +1,71 @@
package order
import (
"crypto/rand"
"fmt"
)
// OrderId is a 64-bit order identifier with a compact base62 string form, the
// same scheme the cart uses (cart.CartId) so ids are consistent across the
// commerce services. The grain is keyed by the raw uint64.
type OrderId uint64
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var base62Rev [256]byte
func init() {
for i := range base62Rev {
base62Rev[i] = 0xFF
}
for i := 0; i < len(base62Alphabet); i++ {
base62Rev[base62Alphabet[i]] = byte(i)
}
}
// String returns the canonical base62 encoding.
func (id OrderId) String() string { return encodeBase62(uint64(id)) }
// NewOrderId generates a cryptographically random non-zero id.
func NewOrderId() (OrderId, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("NewOrderId: %w", err)
}
u := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
if u == 0 {
return NewOrderId()
}
return OrderId(u), nil
}
// ParseOrderId parses a base62 string into an OrderId.
func ParseOrderId(s string) (OrderId, bool) {
if len(s) == 0 || len(s) > 11 {
return 0, false
}
var v uint64
for i := 0; i < len(s); i++ {
d := base62Rev[s[i]]
if d == 0xFF {
return 0, false
}
v = v*62 + uint64(d)
}
return OrderId(v), true
}
func encodeBase62(u uint64) string {
if u == 0 {
return "0"
}
var buf [11]byte
i := len(buf)
for u > 0 {
i--
buf[i] = base62Alphabet[u%62]
u /= 62
}
return string(buf[i:])
}
+249
View File
@@ -0,0 +1,249 @@
package order
import (
"fmt"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
)
// This file holds the mutation handlers. Each handler is the *only* place a
// given transition is allowed; it validates the current status against the
// state machine (canTransition) before mutating, so an illegal event (e.g.
// capturing an unplaced order) returns an error and is never recorded.
//
// Handlers must be deterministic: replaying the event log through them must
// reproduce the same state. Timestamps therefore come from the event payload
// (msToString), never from time.Now().
// require returns an error unless from -> to is a legal transition.
func require(from, to Status, op string) error {
if !canTransition(from, to) {
return fmt.Errorf("order: cannot %s in status %q", op, from)
}
return nil
}
// HandlePlaceOrder creates the order. Legal only when the order is new.
func HandlePlaceOrder(o *OrderGrain, m *messages.PlaceOrder) error {
if o.Status != StatusNew {
return fmt.Errorf("order: already placed (status %q)", o.Status)
}
if len(m.GetLines()) == 0 {
return fmt.Errorf("order: cannot place an order with no lines")
}
o.OrderReference = m.GetOrderReference()
o.CartId = m.GetCartId()
o.Currency = m.GetCurrency()
o.Locale = m.GetLocale()
o.Country = m.GetCountry()
o.TotalAmount = m.GetTotalAmount()
o.TotalTax = m.GetTotalTax()
o.CustomerEmail = m.GetCustomerEmail()
o.CustomerName = m.GetCustomerName()
if b := m.GetBillingAddress(); len(b) > 0 {
o.BillingAddress = append([]byte(nil), b...)
}
if s := m.GetShippingAddress(); len(s) > 0 {
o.ShippingAddress = append([]byte(nil), s...)
}
o.Lines = o.Lines[:0]
for _, l := range m.GetLines() {
o.Lines = append(o.Lines, Line{
Reference: l.GetReference(),
Sku: l.GetSku(),
Name: l.GetName(),
Quantity: int(l.GetQuantity()),
UnitPrice: l.GetUnitPrice(),
TaxRate: int(l.GetTaxRate()),
TotalAmount: l.GetTotalAmount(),
TotalTax: l.GetTotalTax(),
})
}
o.Status = StatusPending
o.PlacedAt = msToString(m.GetPlacedAtMs())
o.touch(o.PlacedAt)
return nil
}
// HandleAuthorizePayment records an authorization: pending -> authorized.
func HandleAuthorizePayment(o *OrderGrain, m *messages.AuthorizePayment) error {
if err := require(o.Status, StatusAuthorized, "authorize payment"); err != nil {
return err
}
at := msToString(m.GetAtMs())
o.Payments = append(o.Payments, &Payment{
Provider: m.GetProvider(),
Authorized: m.GetAmount(),
AuthRef: m.GetReference(),
AuthorizedAt: at,
})
o.Status = StatusAuthorized
o.touch(at)
return nil
}
// HandleCapturePayment records a capture: authorized -> captured.
func HandleCapturePayment(o *OrderGrain, m *messages.CapturePayment) error {
if err := require(o.Status, StatusCaptured, "capture payment"); err != nil {
return err
}
p := o.matchingPayment(m.GetReference(), m.GetProvider())
if p == nil {
return fmt.Errorf("order: capture has no matching authorized payment")
}
at := msToString(m.GetAtMs())
p.Captured += m.GetAmount()
p.CaptureRef = m.GetReference()
p.CapturedAt = at
o.CapturedAmount += m.GetAmount()
o.Status = StatusCaptured
o.touch(at)
return nil
}
// matchingPayment finds the authorized payment this capture belongs to. A mock
// capture reference is "mock-capture-<authRef>"; we match on provider and fall
// back to the first authorized-but-uncaptured payment for that provider.
func (o *OrderGrain) matchingPayment(_ string, provider string) *Payment {
for _, p := range o.Payments {
if p.Provider == provider && p.Captured == 0 {
return p
}
}
if len(o.Payments) > 0 {
return o.Payments[len(o.Payments)-1]
}
return nil
}
// HandleCreateFulfillment ships lines: captured/partially_fulfilled ->
// partially_fulfilled or fulfilled (once every line's quantity has shipped).
func HandleCreateFulfillment(o *OrderGrain, m *messages.CreateFulfillment) error {
if o.Status != StatusCaptured && o.Status != StatusPartiallyFulfilled {
return fmt.Errorf("order: cannot fulfill in status %q", o.Status)
}
f := Fulfillment{
ID: m.GetId(),
Carrier: m.GetCarrier(),
TrackingNumber: m.GetTrackingNumber(),
TrackingURI: m.GetTrackingUri(),
CreatedAt: msToString(m.GetAtMs()),
}
for _, fl := range m.GetLines() {
line := o.findLine(fl.GetReference())
if line == nil {
return fmt.Errorf("order: fulfillment references unknown line %q", fl.GetReference())
}
qty := int(fl.GetQuantity())
if line.Fulfilled+qty > line.Quantity {
return fmt.Errorf("order: fulfillment for line %q exceeds ordered quantity", fl.GetReference())
}
line.Fulfilled += qty
f.Lines = append(f.Lines, FulfillmentEntry{Reference: fl.GetReference(), Quantity: qty})
}
o.Fulfillments = append(o.Fulfillments, f)
target := StatusPartiallyFulfilled
if o.allLinesFulfilled() {
target = StatusFulfilled
}
if err := require(o.Status, target, "fulfill"); err != nil {
return err
}
o.Status = target
o.touch(f.CreatedAt)
return nil
}
// HandleCompleteOrder closes a fully-fulfilled order: fulfilled -> completed.
func HandleCompleteOrder(o *OrderGrain, m *messages.CompleteOrder) error {
if err := require(o.Status, StatusCompleted, "complete"); err != nil {
return err
}
o.Status = StatusCompleted
o.touch(msToString(m.GetAtMs()))
return nil
}
// HandleCancelOrder cancels before capture: pending/authorized -> cancelled.
func HandleCancelOrder(o *OrderGrain, m *messages.CancelOrder) error {
if err := require(o.Status, StatusCancelled, "cancel"); err != nil {
return err
}
o.Status = StatusCancelled
o.touch(msToString(m.GetAtMs()))
return nil
}
// HandleRequestReturn opens an RMA against fulfilled lines. Allowed once an
// order is fulfilled or completed; it does not change the order status.
func HandleRequestReturn(o *OrderGrain, m *messages.RequestReturn) error {
if o.Status != StatusFulfilled && o.Status != StatusCompleted && o.Status != StatusPartiallyFulfilled {
return fmt.Errorf("order: cannot request a return in status %q", o.Status)
}
r := Return{
ID: m.GetId(),
Reason: m.GetReason(),
RequestedAt: msToString(m.GetAtMs()),
}
for _, l := range m.GetLines() {
if o.findLine(l.GetReference()) == nil {
return fmt.Errorf("order: return references unknown line %q", l.GetReference())
}
r.Lines = append(r.Lines, FulfillmentEntry{Reference: l.GetReference(), Quantity: int(l.GetQuantity())})
}
o.Returns = append(o.Returns, r)
o.touch(r.RequestedAt)
return nil
}
// HandleIssueRefund records a refund. Allowed from any captured state; once the
// refunded total reaches the captured total the order becomes refunded.
func HandleIssueRefund(o *OrderGrain, m *messages.IssueRefund) error {
switch o.Status {
case StatusCaptured, StatusPartiallyFulfilled, StatusFulfilled, StatusCompleted:
default:
return fmt.Errorf("order: cannot refund in status %q", o.Status)
}
if m.GetAmount() <= 0 {
return fmt.Errorf("order: refund amount must be positive")
}
if o.RefundedAmount+m.GetAmount() > o.CapturedAmount {
return fmt.Errorf("order: refund exceeds captured amount")
}
at := msToString(m.GetAtMs())
o.Refunds = append(o.Refunds, Refund{
Provider: m.GetProvider(),
Amount: m.GetAmount(),
Reference: m.GetReference(),
ReturnID: m.GetReturnId(),
IssuedAt: at,
})
o.RefundedAmount += m.GetAmount()
for _, p := range o.Payments {
if p.Provider == m.GetProvider() {
p.Refunded += m.GetAmount()
break
}
}
if o.RefundedAmount >= o.CapturedAmount {
o.Status = StatusRefunded
}
o.touch(at)
return nil
}
// RegisterMutations registers every order mutation handler with the registry so
// the grain pool can apply and replay them.
func RegisterMutations(reg actor.MutationRegistry) {
reg.RegisterMutations(
actor.NewMutation(HandlePlaceOrder),
actor.NewMutation(HandleAuthorizePayment),
actor.NewMutation(HandleCapturePayment),
actor.NewMutation(HandleCreateFulfillment),
actor.NewMutation(HandleCompleteOrder),
actor.NewMutation(HandleCancelOrder),
actor.NewMutation(HandleRequestReturn),
actor.NewMutation(HandleIssueRefund),
)
}
+213
View File
@@ -0,0 +1,213 @@
// Package order models an order as an actor grain (same framework as the cart
// and checkout grains). Mutations are the proto messages in proto/order; the
// grain's event log is the source of truth and the OrderGrain below is the
// projection rebuilt by replaying them. The order state machine (transitions
// map + canTransition) is the guardrail enforced inside the mutation handlers.
package order
import (
"encoding/json"
"sync"
"time"
)
// Status is the order lifecycle state.
type Status string
const (
StatusNew Status = "" // not yet placed
StatusPending Status = "pending" // placed, awaiting payment
StatusAuthorized Status = "authorized" // payment authorized
StatusCaptured Status = "captured" // payment captured (paid)
StatusPartiallyFulfilled Status = "partially_fulfilled" // some lines shipped
StatusFulfilled Status = "fulfilled" // all lines shipped
StatusCompleted Status = "completed" // closed out
StatusCancelled Status = "cancelled" // terminal
StatusRefunded Status = "refunded" // terminal
)
// transitions is the legal status graph. A target absent from a source's list
// is rejected by the handler that would have caused it. Returns/refunds that do
// not move status are validated separately in their handlers.
var transitions = map[Status][]Status{
StatusNew: {StatusPending},
StatusPending: {StatusAuthorized, StatusCancelled},
StatusAuthorized: {StatusCaptured, StatusCancelled},
StatusCaptured: {StatusPartiallyFulfilled, StatusFulfilled, StatusRefunded},
StatusPartiallyFulfilled: {StatusPartiallyFulfilled, StatusFulfilled, StatusRefunded},
StatusFulfilled: {StatusCompleted, StatusRefunded},
StatusCompleted: {StatusRefunded},
StatusCancelled: {},
StatusRefunded: {},
}
// canTransition reports whether from -> to is a legal status change.
func canTransition(from, to Status) bool {
for _, s := range transitions[from] {
if s == to {
return true
}
}
return false
}
// Line is an ordered line item (projected from PlaceOrder).
type Line struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int `json:"taxRate"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
// Fulfilled tracks how many units of this line have shipped.
Fulfilled int `json:"fulfilled"`
}
// Payment records one authorization/capture against a provider.
type Payment struct {
Provider string `json:"provider"`
Authorized int64 `json:"authorized"`
Captured int64 `json:"captured"`
Refunded int64 `json:"refunded"`
AuthRef string `json:"authRef,omitempty"`
CaptureRef string `json:"captureRef,omitempty"`
AuthorizedAt string `json:"authorizedAt,omitempty"`
CapturedAt string `json:"capturedAt,omitempty"`
}
// Fulfillment records a shipment of some lines.
type Fulfillment struct {
ID string `json:"id"`
Carrier string `json:"carrier,omitempty"`
TrackingNumber string `json:"trackingNumber,omitempty"`
TrackingURI string `json:"trackingUri,omitempty"`
Lines []FulfillmentEntry `json:"lines,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
// FulfillmentEntry is one shipped line within a fulfillment.
type FulfillmentEntry struct {
Reference string `json:"reference"`
Quantity int `json:"quantity"`
}
// Return records an RMA request.
type Return struct {
ID string `json:"id"`
Reason string `json:"reason,omitempty"`
Lines []FulfillmentEntry `json:"lines,omitempty"`
RequestedAt string `json:"requestedAt,omitempty"`
}
// Refund records a refund issued against the order.
type Refund struct {
Provider string `json:"provider"`
Amount int64 `json:"amount"`
Reference string `json:"reference,omitempty"`
ReturnID string `json:"returnId,omitempty"`
IssuedAt string `json:"issuedAt,omitempty"`
}
// OrderGrain is the projected current state of an order. It implements
// actor.Grain[OrderGrain].
type OrderGrain struct {
mu sync.RWMutex
lastAccess time.Time
lastChange time.Time
Id uint64 `json:"id"`
OrderReference string `json:"orderReference,omitempty"`
CartId string `json:"cartId,omitempty"`
Status Status `json:"status"`
Currency string `json:"currency,omitempty"`
Locale string `json:"locale,omitempty"`
Country string `json:"country,omitempty"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
Lines []Line `json:"lines,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
Payments []*Payment `json:"payments,omitempty"`
Fulfillments []Fulfillment `json:"fulfillments,omitempty"`
Returns []Return `json:"returns,omitempty"`
Refunds []Refund `json:"refunds,omitempty"`
CapturedAmount int64 `json:"capturedAmount"`
RefundedAmount int64 `json:"refundedAmount"`
PlacedAt string `json:"placedAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// NewOrderGrain returns an empty (not-yet-placed) order grain for id.
func NewOrderGrain(id uint64, ts time.Time) *OrderGrain {
return &OrderGrain{
Id: id,
Status: StatusNew,
lastAccess: ts,
lastChange: ts,
}
}
// --- actor.Grain[OrderGrain] ---
func (o *OrderGrain) GetId() uint64 { return o.Id }
func (o *OrderGrain) GetLastAccess() time.Time { return o.lastAccess }
func (o *OrderGrain) GetLastChange() time.Time { return o.lastChange }
func (o *OrderGrain) GetCurrentState() (*OrderGrain, error) {
o.lastAccess = time.Now()
return o, nil
}
func (o *OrderGrain) GetState() ([]byte, error) { return json.Marshal(o) }
// touch records a successful mutation, advancing UpdatedAt and lastChange.
func (o *OrderGrain) touch(at string) {
now := time.Now()
o.lastChange = now
if at != "" {
o.UpdatedAt = at
}
}
// findLine returns the line with the given reference, or nil.
func (o *OrderGrain) findLine(ref string) *Line {
for i := range o.Lines {
if o.Lines[i].Reference == ref {
return &o.Lines[i]
}
}
return nil
}
// allLinesFulfilled reports whether every line's full quantity has shipped.
func (o *OrderGrain) allLinesFulfilled() bool {
if len(o.Lines) == 0 {
return false
}
for _, l := range o.Lines {
if l.Fulfilled < l.Quantity {
return false
}
}
return true
}
// msToString renders a caller-supplied unix-millis timestamp as RFC3339, or ""
// when zero (so replay stays deterministic — the value comes from the event).
func msToString(ms int64) string {
if ms == 0 {
return ""
}
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}
+169
View File
@@ -0,0 +1,169 @@
package order
import (
"context"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"google.golang.org/protobuf/proto"
)
// apply runs one mutation through the registry and returns the handler error
// (the registry surfaces per-mutation handler errors in the result, returning a
// top-level error only for unregistered messages).
func apply(t *testing.T, reg actor.MutationRegistry, g *OrderGrain, msg proto.Message) error {
t.Helper()
results, err := reg.Apply(context.Background(), g, msg)
if err != nil {
return err
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
return results[0].Error
}
func newRegistry() actor.MutationRegistry {
reg := actor.NewMutationRegistry()
RegisterMutations(reg)
return reg
}
func placeMsg() *messages.PlaceOrder {
return &messages.PlaceOrder{
OrderReference: "ref-1",
CartId: "cart-1",
Currency: "SEK",
Country: "se",
TotalAmount: 25000,
TotalTax: 5000,
PlacedAtMs: time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC).UnixMilli(),
Lines: []*messages.OrderLine{
{Reference: "l1", Sku: "ABC", Name: "Widget", Quantity: 2, UnitPrice: 10000, TaxRate: 25, TotalAmount: 20000},
{Reference: "l2", Sku: "DEF", Name: "Gizmo", Quantity: 1, UnitPrice: 5000, TaxRate: 25, TotalAmount: 5000},
},
}
}
func TestHappyPathLifecycle(t *testing.T) {
reg := newRegistry()
g := NewOrderGrain(1, time.Now())
steps := []struct {
name string
msg proto.Message
want Status
}{
{"place", placeMsg(), StatusPending},
{"authorize", &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "mock-auth-ref-1"}, StatusAuthorized},
{"capture", &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "mock-capture-1"}, StatusCaptured},
{"ship l1", &messages.CreateFulfillment{Id: "f1", Lines: []*messages.FulfillmentLine{{Reference: "l1", Quantity: 2}}}, StatusPartiallyFulfilled},
{"ship l2", &messages.CreateFulfillment{Id: "f2", Lines: []*messages.FulfillmentLine{{Reference: "l2", Quantity: 1}}}, StatusFulfilled},
{"complete", &messages.CompleteOrder{}, StatusCompleted},
}
for _, s := range steps {
if err := apply(t, reg, g, s.msg); err != nil {
t.Fatalf("%s: unexpected error: %v", s.name, err)
}
if g.Status != s.want {
t.Fatalf("%s: status = %q, want %q", s.name, g.Status, s.want)
}
}
if g.CapturedAmount != 25000 {
t.Fatalf("captured = %d, want 25000", g.CapturedAmount)
}
}
func TestIllegalTransitionsRejected(t *testing.T) {
reg := newRegistry()
// Capture before placing must fail and leave the order untouched.
g := NewOrderGrain(2, time.Now())
if err := apply(t, reg, g, &messages.CapturePayment{Provider: "mock", Amount: 100}); err == nil {
t.Fatal("capture on a new order should fail")
}
if g.Status != StatusNew {
t.Fatalf("status = %q, want new", g.Status)
}
// After capture, cancel is illegal (must refund instead).
g = NewOrderGrain(3, time.Now())
mustApply(t, reg, g, placeMsg())
mustApply(t, reg, g, &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "a"})
mustApply(t, reg, g, &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "c"})
if err := apply(t, reg, g, &messages.CancelOrder{Reason: "changed mind"}); err == nil {
t.Fatal("cancel after capture should fail")
}
if g.Status != StatusCaptured {
t.Fatalf("status = %q, want captured", g.Status)
}
}
func TestRefundClosesOrder(t *testing.T) {
reg := newRegistry()
g := NewOrderGrain(4, time.Now())
mustApply(t, reg, g, placeMsg())
mustApply(t, reg, g, &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "a"})
mustApply(t, reg, g, &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "c"})
mustApply(t, reg, g, &messages.IssueRefund{Provider: "mock", Amount: 25000, Reference: "r"})
if g.Status != StatusRefunded {
t.Fatalf("status = %q, want refunded", g.Status)
}
if g.RefundedAmount != 25000 {
t.Fatalf("refunded = %d, want 25000", g.RefundedAmount)
}
// Over-refunding must be rejected.
if err := apply(t, reg, g, &messages.IssueRefund{Provider: "mock", Amount: 1, Reference: "r2"}); err == nil {
t.Fatal("refund beyond captured should fail")
}
}
// TestEventSourcedReplay proves the core B1.2 property: the order log is the
// source of truth and replaying it through the registry reproduces the exact
// projected state.
func TestEventSourcedReplay(t *testing.T) {
reg := newRegistry()
storage := actor.NewDiskStorage[OrderGrain](t.TempDir(), reg)
const id = 42
g := NewOrderGrain(id, time.Now())
events := []proto.Message{
placeMsg(),
&messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "a", AtMs: 1},
&messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "c", AtMs: 2},
&messages.CreateFulfillment{Id: "f1", Lines: []*messages.FulfillmentLine{{Reference: "l1", Quantity: 2}}, AtMs: 3},
}
for _, e := range events {
mustApply(t, reg, g, e)
if err := storage.AppendMutations(id, e); err != nil {
t.Fatalf("append: %v", err)
}
}
// Replay into a brand-new grain and compare the projection.
replayed := NewOrderGrain(id, time.Now())
if err := storage.LoadEvents(context.Background(), id, replayed); err != nil {
t.Fatalf("replay: %v", err)
}
if replayed.Status != g.Status {
t.Fatalf("replayed status = %q, want %q", replayed.Status, g.Status)
}
if replayed.CapturedAmount != g.CapturedAmount {
t.Fatalf("replayed captured = %d, want %d", replayed.CapturedAmount, g.CapturedAmount)
}
if len(replayed.Fulfillments) != len(g.Fulfillments) {
t.Fatalf("replayed fulfillments = %d, want %d", len(replayed.Fulfillments), len(g.Fulfillments))
}
if got := replayed.Lines[0].Fulfilled; got != 2 {
t.Fatalf("replayed line l1 fulfilled = %d, want 2", got)
}
}
func mustApply(t *testing.T, reg actor.MutationRegistry, g *OrderGrain, msg proto.Message) {
t.Helper()
if err := apply(t, reg, g, msg); err != nil {
t.Fatalf("apply %T: %v", msg, err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package order
import (
"context"
"fmt"
)
// Authorization is the result of authorizing a payment with a provider.
type Authorization struct {
Provider string
Reference string
Amount int64
}
// Capture is the result of capturing an authorization.
type Capture struct {
Provider string
Reference string
Amount int64
}
// RefundResult is the result of refunding a captured payment.
type RefundResult struct {
Provider string
Reference string
Amount int64
}
// PaymentProvider abstracts a payment processor. It mirrors the ShippingProvider
// seam in go-shipping: one interface, interchangeable implementations. The order
// flow actions (pkg/order/actions.go) call this; the grain only records facts.
type PaymentProvider interface {
Name() string
Authorize(ctx context.Context, orderRef string, amount int64, currency string) (Authorization, error)
Capture(ctx context.Context, authRef string, amount int64) (Capture, error)
Refund(ctx context.Context, captureRef string, amount int64) (RefundResult, error)
Void(ctx context.Context, authRef string) error
}
// MockProvider is a deterministic, always-succeeding payment provider. It lets
// an order be placed and paid end-to-end with no external dependency — the
// simplest path to a captured order for local dev, demos and tests.
type MockProvider struct{}
func NewMockProvider() *MockProvider { return &MockProvider{} }
var _ PaymentProvider = (*MockProvider)(nil)
func (m *MockProvider) Name() string { return "mock" }
func (m *MockProvider) Authorize(_ context.Context, orderRef string, amount int64, _ string) (Authorization, error) {
return Authorization{Provider: m.Name(), Reference: "mock-auth-" + orderRef, Amount: amount}, nil
}
func (m *MockProvider) Capture(_ context.Context, authRef string, amount int64) (Capture, error) {
if authRef == "" {
return Capture{}, fmt.Errorf("mock: capture requires an authorization reference")
}
return Capture{Provider: m.Name(), Reference: "mock-capture-" + authRef, Amount: amount}, nil
}
func (m *MockProvider) Refund(_ context.Context, captureRef string, amount int64) (RefundResult, error) {
if captureRef == "" {
return RefundResult{}, fmt.Errorf("mock: refund requires a capture reference")
}
return RefundResult{Provider: m.Name(), Reference: "mock-refund-" + captureRef, Amount: amount}, nil
}
func (m *MockProvider) Void(_ context.Context, _ string) error { return nil }
+163
View File
@@ -0,0 +1,163 @@
package order
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// StripeProvider implements PaymentProvider against the Stripe REST API using
// PaymentIntents with manual capture (authorize now, capture on fulfillment),
// mirroring the order state machine. It speaks the form-encoded REST API
// directly — no SDK dependency — so it stays in step with the dependency-light
// style of the rest of the module.
//
// Flow mapping:
// - Authorize -> POST /v1/payment_intents (capture_method=manual, confirm=true)
// - Capture -> POST /v1/payment_intents/{id}/capture
// - Refund -> POST /v1/refunds (payment_intent={id})
// - Void -> POST /v1/payment_intents/{id}/cancel
//
// The authorization reference is the PaymentIntent id (pi_...), which is what
// Capture/Refund/Void operate on.
type StripeProvider struct {
secretKey string
baseURL string // defaults to https://api.stripe.com
// PaymentMethod is the payment method id/token to confirm with. For a real
// integration this comes from the client (Stripe.js); for server-side tests
// and headless flows it can be a test token like "pm_card_visa".
PaymentMethod string
client *http.Client
}
var _ PaymentProvider = (*StripeProvider)(nil)
// NewStripeProvider returns a Stripe-backed provider. baseURL may be empty to
// use the live API; tests pass a fake server URL.
func NewStripeProvider(secretKey, baseURL, paymentMethod string) *StripeProvider {
if baseURL == "" {
baseURL = "https://api.stripe.com"
}
if paymentMethod == "" {
paymentMethod = "pm_card_visa" // Stripe test token; override for prod
}
return &StripeProvider{
secretKey: secretKey,
baseURL: strings.TrimSuffix(baseURL, "/"),
PaymentMethod: paymentMethod,
client: &http.Client{Timeout: 20 * time.Second},
}
}
func (s *StripeProvider) Name() string { return "stripe" }
// post sends a form-encoded request and decodes the JSON response, returning an
// error for any non-2xx (surfacing Stripe's error message).
func (s *StripeProvider) post(ctx context.Context, path string, form url.Values) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+path, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.SetBasicAuth(s.secretKey, "")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("stripe: decode %s: %w", path, err)
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("stripe: %s -> %d: %s", path, resp.StatusCode, stripeErr(body))
}
return body, nil
}
func stripeErr(body map[string]any) string {
if e, ok := body["error"].(map[string]any); ok {
if m, ok := e["message"].(string); ok {
return m
}
}
return "unknown error"
}
func strField(body map[string]any, key string) string {
if v, ok := body[key].(string); ok {
return v
}
return ""
}
func (s *StripeProvider) Authorize(ctx context.Context, orderRef string, amount int64, currency string) (Authorization, error) {
form := url.Values{}
form.Set("amount", strconv.FormatInt(amount, 10))
form.Set("currency", strings.ToLower(currency))
form.Set("capture_method", "manual")
form.Set("confirm", "true")
form.Set("payment_method", s.PaymentMethod)
form.Set("description", "order "+orderRef)
// Idempotency on the order reference avoids a duplicate intent on retry.
form.Set("metadata[order_reference]", orderRef)
body, err := s.post(ctx, "/v1/payment_intents", form)
if err != nil {
return Authorization{}, err
}
id := strField(body, "id")
if id == "" {
return Authorization{}, fmt.Errorf("stripe: authorize returned no payment intent id")
}
if status := strField(body, "status"); status != "requires_capture" && status != "succeeded" {
return Authorization{}, fmt.Errorf("stripe: authorize status %q", status)
}
return Authorization{Provider: s.Name(), Reference: id, Amount: amount}, nil
}
func (s *StripeProvider) Capture(ctx context.Context, authRef string, amount int64) (Capture, error) {
if authRef == "" {
return Capture{}, fmt.Errorf("stripe: capture requires a payment intent id")
}
form := url.Values{}
if amount > 0 {
form.Set("amount_to_capture", strconv.FormatInt(amount, 10))
}
body, err := s.post(ctx, "/v1/payment_intents/"+url.PathEscape(authRef)+"/capture", form)
if err != nil {
return Capture{}, err
}
return Capture{Provider: s.Name(), Reference: strField(body, "id"), Amount: amount}, nil
}
func (s *StripeProvider) Refund(ctx context.Context, captureRef string, amount int64) (RefundResult, error) {
if captureRef == "" {
return RefundResult{}, fmt.Errorf("stripe: refund requires a payment intent id")
}
form := url.Values{}
form.Set("payment_intent", captureRef)
if amount > 0 {
form.Set("amount", strconv.FormatInt(amount, 10))
}
body, err := s.post(ctx, "/v1/refunds", form)
if err != nil {
return RefundResult{}, err
}
return RefundResult{Provider: s.Name(), Reference: strField(body, "id"), Amount: amount}, nil
}
func (s *StripeProvider) Void(ctx context.Context, authRef string) error {
if authRef == "" {
return fmt.Errorf("stripe: void requires a payment intent id")
}
_, err := s.post(ctx, "/v1/payment_intents/"+url.PathEscape(authRef)+"/cancel", url.Values{})
return err
}
+91
View File
@@ -0,0 +1,91 @@
package order
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestStripeProviderFlow(t *testing.T) {
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
seen = append(seen, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/v1/payment_intents":
if r.Form.Get("capture_method") != "manual" || r.Form.Get("confirm") != "true" {
t.Errorf("authorize missing manual-capture/confirm: %v", r.Form)
}
if r.Form.Get("amount") != "25000" || r.Form.Get("currency") != "sek" {
t.Errorf("authorize bad amount/currency: %v", r.Form)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "pi_123", "status": "requires_capture"})
case strings.HasSuffix(r.URL.Path, "/capture"):
_ = json.NewEncoder(w).Encode(map[string]any{"id": "pi_123", "status": "succeeded"})
case r.URL.Path == "/v1/refunds":
if r.Form.Get("payment_intent") != "pi_123" {
t.Errorf("refund missing payment_intent: %v", r.Form)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "re_1"})
case strings.HasSuffix(r.URL.Path, "/cancel"):
_ = json.NewEncoder(w).Encode(map[string]any{"id": "pi_123", "status": "canceled"})
default:
http.Error(w, "unexpected", http.StatusNotFound)
}
}))
defer srv.Close()
p := NewStripeProvider("sk_test_x", srv.URL, "pm_card_visa")
ctx := context.Background()
auth, err := p.Authorize(ctx, "ord-1", 25000, "SEK")
if err != nil {
t.Fatalf("authorize: %v", err)
}
if auth.Reference != "pi_123" || auth.Provider != "stripe" {
t.Fatalf("unexpected authorization %+v", auth)
}
if _, err := p.Capture(ctx, auth.Reference, 25000); err != nil {
t.Fatalf("capture: %v", err)
}
ref, err := p.Refund(ctx, auth.Reference, 1000)
if err != nil {
t.Fatalf("refund: %v", err)
}
if ref.Reference != "re_1" {
t.Fatalf("refund ref = %q", ref.Reference)
}
if err := p.Void(ctx, auth.Reference); err != nil {
t.Fatalf("void: %v", err)
}
want := []string{
"POST /v1/payment_intents",
"POST /v1/payment_intents/pi_123/capture",
"POST /v1/refunds",
"POST /v1/payment_intents/pi_123/cancel",
}
if strings.Join(seen, ",") != strings.Join(want, ",") {
t.Fatalf("endpoints called = %v, want %v", seen, want)
}
}
func TestStripeErrorSurfaced(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"message": "Your card was declined."},
})
}))
defer srv.Close()
p := NewStripeProvider("sk_test_x", srv.URL, "pm_card_chargeDeclined")
_, err := p.Authorize(context.Background(), "ord-2", 100, "SEK")
if err == nil || !strings.Contains(err.Error(), "declined") {
t.Fatalf("expected declined error, got %v", err)
}
}