package order import ( "fmt" "git.k6n.net/mats/go-cart-actor/pkg/actor" messages "git.k6n.net/mats/go-cart-actor/proto/order" ) // 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 = m.GetTotalAmount() o.TotalTax = 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: l.GetUnitPrice(), TaxRate: int(l.GetTaxRate()), TotalAmount: l.GetTotalAmount(), TotalTax: l.GetTotalTax(), }) } 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: 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 += m.GetAmount() p.CaptureRef = m.GetReference() p.CapturedAt = at o.CapturedAmount += 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-"; 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+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: m.GetAmount(), Reference: m.GetReference(), ReturnID: m.GetReturnId(), IssuedAt: at, }) o.RefundedAmount += m.GetAmount() for _, p := range o.Payments { if p.Provider == m.GetProvider() { p.Refunded += m.GetAmount() break } } if o.RefundedAmount >= o.CapturedAmount { o.Status = StatusRefunded } o.touch(at) 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), ) }