// mutation_items.go // // Single concern file for the cart line-item lifecycle: AddItem + // RemoveItem (and the helpers they share). Pulled out of the per-mutation // file layout as a proof-of-grouping for the pattern in // pkg/cart/MUTATIONS.md. AddItem and RemoveItem are dispatched through the // same MutationRegistry (cart-mutation-helper.go) and operate on the same // *CartGrain, so keeping them adjacent lets future reviewers compare // "what does add do when an item already exists" against "what does // remove do when a parent is removed" without bouncing between files. // // What lives here: // // - AddItem: validation, merge-into-existing by ItemId, reservation // handling, totals. // - RemoveItem: by-line-id removal with transitive parent→child // cascade, reservation release, totals. // - Shared package helpers: decodeExtra (dynamic product extra JSON), // getOrgPrice (origin price helper), ErrPaymentInProgress (used by // mutation_add_voucher.go to refuse stacking during checkout). // // Out of scope here: change_quantity, set_custom_fields, line markings, // subscription details, etc. Those are separate concerns and stay in // their own files until they grow enough behaviour to invite merging. package cart import ( "context" "encoding/json" "errors" "fmt" "log" "time" cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" "google.golang.org/protobuf/types/known/timestamppb" ) // ErrPaymentInProgress is returned by mutations that must not proceed // while the cart is mid-payment (currently referenced by // mutation_add_voucher.go to short-circuit adding a voucher mid-checkout). var ErrPaymentInProgress = errors.New("payment in progress") // decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid // payloads are logged and dropped rather than failing the mutation. func decodeExtra(b []byte) map[string]json.RawMessage { if len(b) == 0 { return nil } m := map[string]json.RawMessage{} if err := json.Unmarshal(b, &m); err != nil { log.Printf("AddItem: invalid extra_json: %v", err) return nil } return m } func getOrgPrice(orgPrice int64, rateBp int) *Price { if orgPrice <= 0 { return nil } return NewPriceFromIncVat(orgPrice, rateBp) } // AddItem (formerly mutation_add_item.go). // // Registers the AddItem cart mutation in the generic mutation registry. // This replaces the legacy switch-based logic previously found in CartGrain.Apply. // // Behaviour: // - Validates quantity > 0 // - If an item with the same item id (ItemId) exists -> increases quantity // - Else creates a new CartItem with computed tax amounts // - Totals recalculated automatically via WithTotals() // // Item identity is the catalog item id (ItemId), not the SKU: the product // service is looked up by id and the returned SKU is reference-only. // // NOTE: Any future field additions in messages.AddItem that affect pricing / // tax must keep this handler in sync. func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error { ctx := context.Background() if m == nil { return fmt.Errorf("AddItem: nil payload") } if m.Quantity < 1 { return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity) } // Merge with any existing item having the same item id and matching StoreId // (including both nil). Identity is the id; SKU is reference-only. for _, existing := range g.Items { if existing.ItemId != m.ItemId { continue } sameStore := (existing.StoreId == nil && m.StoreId == nil) || (existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId) if !sameStore { continue } if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(existing) { if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil { log.Printf("failed to release item %d: %v", existing.Id, err) } endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity)) if err != nil { return err } existing.ReservationEndTime = endTime } oldQty := existing.Quantity existing.Quantity += uint16(m.Quantity) existing.Stock = uint16(m.Stock) existing.InventoryTracked = m.InventoryTracked existing.DropShip = m.DropShip // If existing had nil store but new has one, adopt it. if existing.StoreId == nil && m.StoreId != nil { existing.StoreId = m.StoreId } // Refresh dynamic product data with the latest payload. if extra := decodeExtra(m.ExtraJson); extra != nil { existing.Extra = extra } // Replace custom fields when provided on the re-add. if len(m.CustomFields) > 0 { existing.CustomFields = m.CustomFields } // Re-add grew the parent's qty — rescale direct child quantities // proportionally so a "+1 drill" keeps its accessories consistent. // Skip when the merged line IS itself a child (ParentId != nil): // cascading up the tree isn't a thing — the parent would not know // a child grew without re-running the promotion engine. if existing.ParentId == nil { c.CascadeChildQuantities(ctx, g, existing.Id, oldQty, existing.Quantity) } // Recompute totals so TotalPrice/TotalDiscount/Discount annotations // reflect the merged parent + cascaded children. The non-merge // (new-line) path below also calls UpdateTotals; the merge path // would otherwise drift after a "+1" re-add, especially now that // cascade may have rewritten N child quantities. g.UpdateTotals() return nil } g.mu.Lock() defer g.mu.Unlock() g.lastItemId++ // m.Tax is the rate in basis points (2500 = 25%, 1250 = 12.5%) — the single // platform scale; flows straight through, no conversion. rateBp := 2500 if m.Tax > 0 { rateBp = int(m.Tax) } pricePerItem := NewPriceFromIncVat(m.Price, rateBp) needsReservation := true if m.ReservationEndTime != nil { needsReservation = m.ReservationEndTime.AsTime().Before(time.Now()) } cartItem := &CartItem{ Id: g.lastItemId, ItemId: uint32(m.ItemId), Quantity: uint16(m.Quantity), Sku: m.Sku, Tax: rateBp, Meta: &ItemMeta{ Name: m.Name, Image: m.Image, Brand: m.Brand, Category: m.Category, Category2: m.Category2, Category3: m.Category3, Category4: m.Category4, Category5: m.Category5, Outlet: m.Outlet, SellerName: m.SellerName, }, SellerId: m.SellerId, Cgm: m.Cgm, SaleStatus: m.SaleStatus, ParentId: m.ParentId, Price: *pricePerItem, TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)), Stock: uint16(m.Stock), Disclaimer: m.Disclaimer, OrgPrice: getOrgPrice(m.OrgPrice, rateBp), ArticleType: m.ArticleType, StoreId: m.StoreId, Extra: decodeExtra(m.ExtraJson), CustomFields: m.CustomFields, InventoryTracked: m.InventoryTracked, DropShip: m.DropShip, } if g.Type == cart_messages.CartType_REGULAR && needsReservation && c.UseReservations(cartItem) { endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity)) if err != nil { return err } if endTime != nil { m.ReservationEndTime = timestamppb.New(*endTime) t := m.ReservationEndTime.AsTime() cartItem.ReservationEndTime = &t } } g.Items = append(g.Items, cartItem) g.UpdateTotals() return nil } // RemoveItem (formerly mutation_remove_item.go). // // Registers the RemoveItem mutation. // // Behaviour: // - Removes the cart line whose local cart line Id == payload.Id // - Cascades: also removes any line whose ParentId points at a removed line // (transitively), so removing a parent removes its child sub-articles // - If no such line exists returns an error // - Releases reservations for every removed line and recalculates totals // // Notes: // - This removes only the line items; any deliveries referencing a removed // item are NOT automatically adjusted (mirrors prior logic). If future // semantics require pruning delivery.item_ids, extend this handler. // - If multiple lines somehow shared the same Id (should not happen), all // matches are removed — data integrity relies on unique line Ids. func (c *CartMutationContext) RemoveItem(g *CartGrain, m *cart_messages.RemoveItem) error { if m == nil { return fmt.Errorf("RemoveItem: nil payload") } targetID := uint32(m.Id) found := false for _, it := range g.Items { if it.Id == targetID { found = true break } } if !found { return fmt.Errorf("RemoveItem: item id %d not found", m.Id) } // Collect the target and, transitively, any children pointing at a removed // line. Loops until no further descendants are found (handles nesting). remove := map[uint32]bool{targetID: true} for { grew := false for _, it := range g.Items { if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] { remove[it.Id] = true grew = true } } if !grew { break } } kept := g.Items[:0] for _, it := range g.Items { if remove[it.Id] { if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) { if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil { log.Printf("unable to release reservation for item %d: %v", it.Id, err) } } continue } kept = append(kept, it) } g.Items = kept g.UpdateTotals() return nil }