// 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" "git.k6n.net/mats/platform/money" ) // 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 money.Cents `json:"unitPrice"` TaxRate int `json:"taxRate"` TotalAmount money.Cents `json:"totalAmount"` TotalTax money.Cents `json:"totalTax"` // Location is the inventory location / store id to commit against (empty = // order country). Carried through to the order.created event for commit. Location string `json:"location,omitempty"` // 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 money.Cents `json:"authorized"` Captured money.Cents `json:"captured"` Refunded money.Cents `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"` } // Exchange records an exchange request. type Exchange struct { ID string `json:"id"` ReturnID string `json:"returnId"` Reason string `json:"reason,omitempty"` ReturnLines []FulfillmentEntry `json:"returnLines,omitempty"` NewLines []Line `json:"newLines,omitempty"` RequestedAt string `json:"requestedAt,omitempty"` } // Refund records a refund issued against the order. type Refund struct { Provider string `json:"provider"` Amount money.Cents `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 money.Cents `json:"totalAmount"` TotalTax money.Cents `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"` Exchanges []Exchange `json:"exchanges,omitempty"` Refunds []Refund `json:"refunds,omitempty"` CapturedAmount money.Cents `json:"capturedAmount"` RefundedAmount money.Cents `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) }