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 } // 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: nl.GetUnitPrice(), TaxRate: int(nl.GetTaxRate()), TotalAmount: nl.GetTotalAmount(), TotalTax: 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 := m.GetShippingPrice(); sp > 0 { found := false var oldTotal int64 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 * 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: 25, TotalAmount: sp, TotalTax: ComputeTax(sp, 25), } 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), ) }