226 lines
7.5 KiB
Go
226 lines
7.5 KiB
Go
// 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"`
|
|
}
|
|
|
|
// 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 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"`
|
|
Exchanges []Exchange `json:"exchanges,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)
|
|
}
|