Files
go-cart-actor/pkg/order/order-grain.go
T
mats 528c59bfd3
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
cart and checkout
2026-06-27 19:49:00 +02:00

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