353 lines
11 KiB
Go
353 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(),
|
|
})
|
|
}
|
|
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),
|
|
)
|
|
}
|
|
|
|
|