Files
go-cart-actor/pkg/cart/mutation_items.go
T
mats 8bb0a7a786
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s
tests and mutation docs
2026-07-03 19:30:02 +02:00

273 lines
8.3 KiB
Go

// 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
}
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
}
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
}