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