273 lines
8.3 KiB
Go
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
|
|
}
|