Files
go-cart-actor/pkg/order/mutations.go
T
mats 46be260fcc
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
refactor
2026-06-29 12:34:58 +02:00

354 lines
11 KiB
Go

package order
import (
"fmt"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/money"
)
// 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 = money.Cents(m.GetTotalAmount())
o.TotalTax = money.Cents(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: money.Cents(l.GetUnitPrice()),
TaxRate: int(l.GetTaxRate()),
TotalAmount: money.Cents(l.GetTotalAmount()),
TotalTax: money.Cents(l.GetTotalTax()),
Location: l.GetLocation(),
DropShip: l.GetDropShip(),
})
}
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: money.Cents(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 += money.Cents(m.GetAmount())
p.CaptureRef = m.GetReference()
p.CapturedAt = at
o.CapturedAmount += money.Cents(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+money.Cents(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: money.Cents(m.GetAmount()),
Reference: m.GetReference(),
ReturnID: m.GetReturnId(),
IssuedAt: at,
})
o.RefundedAmount += money.Cents(m.GetAmount())
for _, p := range o.Payments {
if p.Provider == m.GetProvider() {
p.Refunded += money.Cents(m.GetAmount())
break
}
}
if o.RefundedAmount >= o.CapturedAmount {
o.Status = StatusRefunded
}
o.touch(at)
return nil
}
// HandleRequestExchange records an exchange request.
func HandleRequestExchange(o *OrderGrain, m *messages.RequestExchange) error {
if o.Status != StatusFulfilled && o.Status != StatusCompleted && o.Status != StatusPartiallyFulfilled {
return fmt.Errorf("order: cannot request an exchange in status %q", o.Status)
}
ex := Exchange{
ID: m.GetId(),
ReturnID: m.GetReturnId(),
Reason: m.GetReason(),
RequestedAt: msToString(m.GetAtMs()),
}
// 1. Process return lines
for _, rl := range m.GetReturnLines() {
line := o.findLine(rl.GetReference())
if line == nil {
return fmt.Errorf("order: exchange return references unknown line %q", rl.GetReference())
}
ex.ReturnLines = append(ex.ReturnLines, FulfillmentEntry{
Reference: rl.GetReference(),
Quantity: int(rl.GetQuantity()),
})
}
// 2. Process new lines (replacement items)
for _, nl := range m.GetNewLines() {
line := Line{
Reference: nl.GetReference(),
Sku: nl.GetSku(),
Name: nl.GetName(),
Quantity: int(nl.GetQuantity()),
UnitPrice: money.Cents(nl.GetUnitPrice()),
TaxRate: int(nl.GetTaxRate()),
TotalAmount: money.Cents(nl.GetTotalAmount()),
TotalTax: money.Cents(nl.GetTotalTax()),
}
ex.NewLines = append(ex.NewLines, line)
// Add the replacement line to the order so it can be fulfilled
o.Lines = append(o.Lines, line)
}
// 3. Since there are new lines that are not yet fulfilled, transition order status back to partially fulfilled
o.Status = StatusPartiallyFulfilled
o.Exchanges = append(o.Exchanges, ex)
o.touch(ex.RequestedAt)
return nil
}
// HandleEditOrderDetails updates addresses or shipping price.
func HandleEditOrderDetails(o *OrderGrain, m *messages.EditOrderDetails) error {
if o.Status == StatusCompleted || o.Status == StatusCancelled || o.Status == StatusRefunded {
return fmt.Errorf("order: cannot edit details in status %q", o.Status)
}
if s := m.GetShippingAddress(); len(s) > 0 {
o.ShippingAddress = append([]byte(nil), s...)
}
if b := m.GetBillingAddress(); len(b) > 0 {
o.BillingAddress = append([]byte(nil), b...)
}
if sp := money.Cents(m.GetShippingPrice()); sp > 0 {
found := false
var oldTotal money.Cents
for i := range o.Lines {
if o.Lines[i].Name == "Delivery" {
oldTotal = o.Lines[i].TotalAmount
o.Lines[i].UnitPrice = sp
o.Lines[i].TotalAmount = sp.Mul(int64(o.Lines[i].Quantity))
o.Lines[i].TotalTax = ComputeTax(o.Lines[i].TotalAmount, o.Lines[i].TaxRate)
o.TotalAmount = o.TotalAmount - oldTotal + o.Lines[i].TotalAmount
found = true
break
}
}
if !found {
line := Line{
Reference: "delivery",
Sku: "delivery",
Name: "Delivery",
Quantity: 1,
UnitPrice: sp,
TaxRate: 2500, // 25% in basis points
TotalAmount: sp,
TotalTax: ComputeTax(sp, 2500),
}
o.Lines = append(o.Lines, line)
o.TotalAmount += sp
}
}
o.touch(msToString(m.GetAtMs()))
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),
actor.NewMutation(HandleRequestExchange),
actor.NewMutation(HandleEditOrderDetails),
)
}