diff --git a/cmd/checkout/order_create.go b/cmd/checkout/order_create.go index 2aefb7d..08d2da9 100644 --- a/cmd/checkout/order_create.go +++ b/cmd/checkout/order_create.go @@ -6,7 +6,9 @@ import ( "fmt" "git.k6n.net/mats/go-cart-actor/pkg/checkout" + profile "git.k6n.net/mats/go-cart-actor/pkg/profile" messages "git.k6n.net/mats/go-cart-actor/proto/checkout" + profileMessages "git.k6n.net/mats/go-cart-actor/proto/profile" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -160,6 +162,15 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, Status: "completed", CreatedAt: timestamppb.Now(), }) + if grain.CartState != nil && grain.CartState.UserId != "" { + if pid, ok := profile.ParseProfileId(grain.CartState.UserId); ok { + _ = s.ApplyAnywhere(ctx, checkout.CheckoutId(pid), &profileMessages.LinkOrder{ + OrderReference: result.OrderId, + CartId: uint64(grain.CartId), + Status: "completed", + }) + } + } return result, nil } createErr = err diff --git a/pkg/cart/cart-grain.go b/pkg/cart/cart-grain.go index 361c788..b9e71da 100644 --- a/pkg/cart/cart-grain.go +++ b/pkg/cart/cart-grain.go @@ -119,6 +119,7 @@ type AppliedPromotion struct { Type string `json:"type,omitempty"` Label string `json:"label,omitempty"` Discount *Price `json:"discount,omitempty"` + ItemIds []uint32 `json:"itemIds,omitempty"` Pending bool `json:"pending,omitempty"` Progress map[string]interface{} `json:"progress,omitempty"` @@ -130,8 +131,8 @@ type CartGrain struct { lastVoucherId uint32 lastAccess time.Time lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts) - userId string - Currency string `json:"currency"` + UserId string `json:"userId,omitempty"` + Currency string `json:"currency"` Language string `json:"language"` Version uint `json:"version"` InventoryReserved bool `json:"inventoryReserved"` diff --git a/pkg/cart/mutation_set_user_id.go b/pkg/cart/mutation_set_user_id.go index e5a2dfa..89a1899 100644 --- a/pkg/cart/mutation_set_user_id.go +++ b/pkg/cart/mutation_set_user_id.go @@ -10,6 +10,6 @@ func SetUserId(grain *CartGrain, req *messages.SetUserId) error { if req.UserId == "" { return errors.New("user ID cannot be empty") } - grain.userId = req.UserId + grain.UserId = req.UserId return nil } diff --git a/pkg/promotions/apply.go b/pkg/promotions/apply.go index 8e2664b..7245096 100644 --- a/pkg/promotions/apply.go +++ b/pkg/promotions/apply.go @@ -31,7 +31,7 @@ type Effect interface { // Apply mutates the cart for a qualifying action and returns the discount it // took (nil for non-monetary effects such as free shipping) plus whether // anything took effect worth recording. - Apply(g *cart.CartGrain, rule PromotionRule, a Action) (discount *cart.Price, applied bool) + Apply(g *cart.CartGrain, rule PromotionRule, a Action) (discount *cart.Price, itemIds []uint32, applied bool) // Progress reports how far the cart is from (further) unlocking this action, // as an open key/value payload. qualified says whether the owning rule // currently applies, letting the effect distinguish "remaining to unlock" @@ -77,8 +77,9 @@ func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationR } recorded := false if res.Applicable { - if discount, applied := eff.Apply(g, res.Rule, a); applied { + if discount, itemIds, applied := eff.Apply(g, res.Rule, a); applied { entry.Discount = discount + entry.ItemIds = itemIds recorded = true } } @@ -111,9 +112,9 @@ type percentageDiscountEffect struct{} func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount } -func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) { +func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, []uint32, bool) { d := applyPercentageDiscount(g, percentFromValue(a.Value)) - return d, d != nil + return d, nil, d != nil } func (percentageDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { @@ -129,8 +130,8 @@ type freeShippingEffect struct{} func (freeShippingEffect) Type() ActionType { return ActionFreeShipping } -func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, bool) { - return nil, true +func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, []uint32, bool) { + return nil, nil, true } func (freeShippingEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { @@ -148,9 +149,9 @@ type tieredDiscountEffect struct{} func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount } -func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) { +func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, []uint32, bool) { d := applyTieredDiscount(g, a) - return d, d != nil + return d, nil, d != nil } func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { @@ -387,7 +388,7 @@ type unitItem struct { price money.Cents } -func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) { +func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, []uint32, bool) { config := a.Config buy := int(firstNum(config, "buy")) if buy <= 0 { @@ -416,7 +417,7 @@ func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*c n := len(units) numFree := (n / (buy + get)) * get if numFree <= 0 { - return nil, false + return nil, nil, false } // Sort units by price ascending so we discount the cheapest ones @@ -431,13 +432,15 @@ func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*c }) totalDiscount := cart.NewPrice() + affectedMap := make(map[uint32]bool) for i := 0; i < numFree; i++ { unitDiscount := scalePrice(&units[i].item.Price, discount) totalDiscount.Add(*unitDiscount) + affectedMap[units[i].item.Id] = true } if totalDiscount.IncVat <= 0 { - return nil, false + return nil, nil, false } // Never discount below zero. if totalDiscount.IncVat > g.TotalPrice.IncVat { @@ -445,7 +448,14 @@ func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*c } g.TotalDiscount.Add(*totalDiscount) g.TotalPrice.Subtract(*totalDiscount) - return totalDiscount, true + + var itemIds []uint32 + for id := range affectedMap { + itemIds = append(itemIds, id) + } + slices.Sort(itemIds) + + return totalDiscount, itemIds, true } func (buyXGetYEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { @@ -460,9 +470,9 @@ type bundleDiscountEffect struct{} func (bundleDiscountEffect) Type() ActionType { return ActionBundleDiscount } -func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) { +func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, []uint32, bool) { if a.BundleConfig == nil { - return nil, false + return nil, nil, false } // 1. Represent all cart items as individual units @@ -545,10 +555,11 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) } if len(formedBundles) == 0 { - return nil, false + return nil, nil, false } totalDiscount := cart.NewPrice() + affectedMap := make(map[uint32]bool) for _, bundle := range formedBundles { // Calculate the original bundle price bundlePrice := cart.NewPrice() @@ -579,11 +590,14 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) if bundleDiscount != nil && bundleDiscount.IncVat > 0 { totalDiscount.Add(*bundleDiscount) + for _, item := range bundle { + affectedMap[item.Id] = true + } } } if totalDiscount.IncVat <= 0 { - return nil, false + return nil, nil, false } // Never discount below zero. if totalDiscount.IncVat > g.TotalPrice.IncVat { @@ -591,7 +605,14 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) } g.TotalDiscount.Add(*totalDiscount) g.TotalPrice.Subtract(*totalDiscount) - return totalDiscount, true + + var itemIds []uint32 + for id := range affectedMap { + itemIds = append(itemIds, id) + } + slices.Sort(itemIds) + + return totalDiscount, itemIds, true } func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { diff --git a/pkg/promotions/apply_test.go b/pkg/promotions/apply_test.go index f1f76c3..e4632a4 100644 --- a/pkg/promotions/apply_test.go +++ b/pkg/promotions/apply_test.go @@ -184,6 +184,13 @@ func TestBuyXGetYEffect(t *testing.T) { if got := g.TotalPrice.IncVat; got != 2000 { t.Errorf("total price = %d, want 2000", got) } + if len(g.AppliedPromotions) != 1 { + t.Fatalf("expected 1 applied promotion, got %d", len(g.AppliedPromotions)) + } + itemIds := g.AppliedPromotions[0].ItemIds + if len(itemIds) != 1 || itemIds[0] != 1 { + t.Errorf("expected affected itemIds [1], got %v", itemIds) + } } func TestBundleDiscountEffect(t *testing.T) { @@ -247,4 +254,11 @@ func TestBundleDiscountEffect(t *testing.T) { if got := g.TotalPrice.IncVat; got != 4000 { t.Errorf("total price = %d, want 4000", got) } + if len(g.AppliedPromotions) != 1 { + t.Fatalf("expected 1 applied promotion, got %d", len(g.AppliedPromotions)) + } + itemIds := g.AppliedPromotions[0].ItemIds + if len(itemIds) != 2 || itemIds[0] != 1 || itemIds[1] != 2 { + t.Errorf("expected affected itemIds [1, 2], got %v", itemIds) + } }