package cart import ( "context" "log" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" "git.k6n.net/mats/platform/inventory" ) type CartMutationContext struct { reservationService inventory.ReservationPolicy } func NewCartMutationContext(reservationService inventory.ReservationPolicy) *CartMutationContext { return &CartMutationContext{ reservationService: reservationService, } } func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sku string, locationId *string, quantity uint16) (*time.Time, error) { if quantity <= 0 || c.reservationService == nil { return nil, nil } l := inventory.LocationID("se") if locationId != nil { l = inventory.LocationID(*locationId) } ttl := time.Minute * 15 endTime := time.Now().Add(ttl) err := c.reservationService.Reserve(ctx, inventory.CartReserveRequest{ CartID: inventory.CartID(cartId.String()), InventoryReference: &inventory.InventoryReference{ SKU: inventory.SKU(sku), LocationID: l, }, TTL: ttl, Quantity: uint32(quantity), }) if err != nil { return nil, err } return &endTime, nil } func (c *CartMutationContext) UseReservations(item *CartItem) bool { if item.ReservationEndTime != nil { return true } if item.DropShip { return false } if item.InventoryTracked { return true } return false } func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error { if c.reservationService == nil { return nil } l := inventory.LocationID("se") if locationId != nil { l = inventory.LocationID(*locationId) } return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String())) } // equalOptional is the canonical nullable-equality primitive used by the // cart mutations when deciding whether two identifier fields (parent/child // links, store ids, anything else nullable) refer to the same thing. // // Returns true when both pointers are nil, when both alias the same // address, or when both are non-nil and dereference to equal values. The // same-address short-circuit is a no-op for correctness but lets a caller // who passes the same variable get back true without traversing the // generic's comparison path. // // One tested primitive covers every *T the cart currently compares — // `*uint32` for ParentId, `*string` for StoreId — and any future // comparable pointer type. Hand-rolled "both nil OR both non-nil and // equal" expressions were duplicated at AddItem's merge site and were a // footgun whenever a new field was added (which axis of the predicate // was the relevant one?). Keeping a single helper makes future merges // safer to copy. func equalOptional[T comparable](a, b *T) bool { if a == b { // both nil OR same address return true } if a == nil || b == nil { return false } return *a == *b } // CascadeChildQuantities scales every direct child's quantity // proportionally when a parent's quantity is bumped (ChangeQuantity or // the AddItem-merge path). // // - Ratio: newChildQty = oldChildQty × newParentQty / oldParentQty. // - Floor: scale-down via integer division can yield 0 (e.g. parent // 2 → 1, child qty 1 → (1×1)/2 = 0). We clamp to 1 so a scale-down // never silently deletes an accessory. The mismatch is acceptable — // shipping one extra child is better than dropping one. // - Reservations: each child's reservation is released then re-acquired // at the new quantity if UseReservations is true for that child AND a // prior reservation existed. A failing reservation is logged, not // raised, so a single bad child doesn't abort the whole cascade // (matches the existing removal pattern in mutation_items.go). // - Recursion: descends into grandchildren using the child's own // before/after quantities as the next ratio's input, so deeply // nested accessory trees (parent → child → grandchild) stay in // ratio with the top-level change. // // Caller is responsible for locking any grain mutex and for re-running // UpdateTotals after the cascade has settled. func (c *CartMutationContext) CascadeChildQuantities( ctx context.Context, g *CartGrain, parentLineId uint32, oldParentQty uint16, newParentQty uint16, ) { if oldParentQty == 0 || oldParentQty == newParentQty { return } for _, it := range g.Items { if it == nil { continue } if it.ParentId == nil || *it.ParentId != parentLineId { continue } oldChildQty := it.Quantity ratio := uint32(newParentQty) * uint32(oldChildQty) / uint32(oldParentQty) newChildQty := uint16(ratio) if newChildQty == 0 { newChildQty = 1 } if newChildQty == oldChildQty { continue } if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(it) && it.ReservationEndTime != nil { if err := c.ReleaseItem(ctx, g.Id, it.Sku, it.StoreId); err != nil { log.Printf("CascadeChildQuantities: failed to release %s: %v", it.Sku, err) } endTime, err := c.ReserveItem(ctx, g.Id, it.Sku, it.StoreId, newChildQty) if err != nil { log.Printf("CascadeChildQuantities: failed to reserve %s at qty %d: %v", it.Sku, newChildQty, err) } else if endTime != nil { it.ReservationEndTime = endTime } } it.Quantity = newChildQty // Descend so grandchildren track this child's ratio. c.CascadeChildQuantities(ctx, g, it.Id, oldChildQty, newChildQty) } } func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry { reg := actor.NewMutationRegistry() reg.RegisterMutations( actor.NewMutation(context.AddItem), actor.NewMutation(context.ChangeQuantity), actor.NewMutation(context.RemoveItem), actor.NewMutation(ClearCart), actor.NewMutation(AddVoucher), actor.NewMutation(RemoveVoucher), actor.NewMutation(UpsertSubscriptionDetails), actor.NewMutation(SetUserId), actor.NewMutation(LineItemMarking), actor.NewMutation(RemoveLineItemMarking), actor.NewMutation(SetLineItemCustomFields), actor.NewMutation(SubscriptionAdded), actor.NewMutation(context.SetCartType), actor.NewMutation(SetRecoveryContact), // actor.NewMutation(SubscriptionRemoved), ) return reg }