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