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
+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),
)
}