package cart import ( "encoding/json" "sync" "time" "git.k6n.net/mats/go-cart-actor/pkg/voucher" cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" ) // Legacy padded [16]byte CartId and its helper methods removed. // Unified CartId (uint64 with base62 string form) now defined in cart_id.go. type StockStatus int type ItemMeta struct { Name string `json:"name"` Brand string `json:"brand,omitempty"` Category string `json:"category,omitempty"` Category2 string `json:"category2,omitempty"` Category3 string `json:"category3,omitempty"` Category4 string `json:"category4,omitempty"` Category5 string `json:"category5,omitempty"` SellerName string `json:"sellerName,omitempty"` Image string `json:"image,omitempty"` Outlet *string `json:"outlet,omitempty"` } type CartItem struct { Id uint32 `json:"id"` ItemId uint32 `json:"itemId,omitempty"` ParentId *uint32 `json:"parentId,omitempty"` Sku string `json:"sku"` Price Price `json:"price"` TotalPrice Price `json:"totalPrice"` SellerId string `json:"sellerId,omitempty"` OrgPrice *Price `json:"orgPrice,omitempty"` Cgm string `json:"cgm,omitempty"` Tax int `json:"tax"` Stock uint16 `json:"stock"` Quantity uint16 `json:"qty"` Discount *Price `json:"discount,omitempty"` Disclaimer string `json:"disclaimer,omitempty"` ArticleType string `json:"type,omitempty"` StoreId *string `json:"storeId,omitempty"` Meta *ItemMeta `json:"meta,omitempty"` SaleStatus string `json:"saleStatus"` Marking *Marking `json:"marking,omitempty"` // CustomFields holds optional user-supplied input fields for this line // (engraving text, configurator notes, ...), keyed by field name. CustomFields map[string]string `json:"customFields,omitempty"` SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"` OrderReference string `json:"orderReference,omitempty"` IsSubscribed bool `json:"isSubscribed,omitempty"` ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"` InventoryTracked bool `json:"inventoryTracked,omitempty"` DropShip bool `json:"dropShip,omitempty"` // Extra holds arbitrary dynamic product data. Its keys are flattened onto // the item object in JSON (see cart_item_json.go), so they are returned // over the API alongside the typed fields. Typed fields win on key // collisions. Extra map[string]json.RawMessage `json:"-"` } type CartNotification struct { LinkedId int `json:"id"` Provider string `json:"provider"` Title string `json:"title"` Content string `json:"content"` } type SubscriptionDetails struct { Id string `json:"id,omitempty"` Version uint16 `json:"version"` OfferingCode string `json:"offeringCode,omitempty"` SigningType string `json:"signingType,omitempty"` Meta json.RawMessage `json:"data,omitempty"` } type Notice struct { Timestamp time.Time `json:"timestamp"` Message string `json:"message"` Code *string `json:"code,omitempty"` } // PushToken is the Go-level mirror of cart_messages.PushToken: a generic, // provider-agnostic push-delivery target. Stored on the grain so the // abandoned-cart recovery scanner can hand it to a notifier without // re-decoding proto messages; the wire shape lives in proto/cart.proto. // // SECURITY: the raw Token persists verbatim to the per-pod event log // (CartGrain JSON-marshals PushTokens on every mutation). Anyone with read // access to the cart service's data dir — PVC snapshots, debug dumps, log // archival — can extract device handles. A future hardening pass should // either hash-on-write (hash.compareAtLaunch) for stateless matching or // encrypt-at-rest with a key the notifier owns. v0 keeps the seam: a real // notifier should treat the stored token as opaque, fetch any canonical // canonicalised handle from the input side (frontend/web SDK), and use // what's here only for routing. type PushToken struct { Platform string `json:"platform"` Token string `json:"token"` } type CartPaymentStatus string const ( CartPaymentStatusPending CartPaymentStatus = "pending" CartPaymentStatusFailed CartPaymentStatus = "failed" CartPaymentStatusSuccess CartPaymentStatus = "success" CartPaymentStatusCancelled CartPaymentStatus = "partial" ) type Marking struct { Type uint32 `json:"type"` Text string `json:"text"` } // AppliedPromotion records a single promotion action's effect on the cart. It is // rebuilt every time promotions are (re-)applied so the API result exposes a // per-promotion breakdown rather than only the aggregate TotalDiscount. // // An entry is one of two kinds, both living in the same list: // - applied (Pending=false): the action took effect now. Discount holds the // amount taken off (nil for non-monetary effects such as free_shipping). // - pending (Pending=true): the action has NOT qualified yet but is within // reach. Progress carries effect-specific data describing what's left to // unlock it — e.g. {"remaining": 120000, "threshold": 500000} for a // cart-total nudge. Keeping it an open map lets new effect types surface // their own progress fields without changing this struct. This powers // "spend X more for ..." nudges generically. type AppliedPromotion struct { PromotionId string `json:"promotionId,omitempty"` Name string `json:"name,omitempty"` ActionId string `json:"actionId,omitempty"` 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"` } type CartGrain struct { mu sync.RWMutex lastItemId uint32 lastVoucherId uint32 lastAccess time.Time lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts) UserId string `json:"userId,omitempty"` Currency string `json:"currency"` Language string `json:"language"` Version uint `json:"version"` InventoryReserved bool `json:"inventoryReserved"` Type cart_messages.CartType `json:"type,omitempty"` Id CartId `json:"id"` Items []*CartItem `json:"items"` TotalPrice *Price `json:"totalPrice"` TotalDiscount *Price `json:"totalDiscount"` // EvaluatedItems is the per-line promotion breakdown — see // EvaluatedItem for shape and semantics. Populated by the canonical // promotion pipeline (pkg/promotions.PromotionService.EvaluateAndApply) // after every successful mutation, alongside TotalDiscount and // AppliedPromotions, so any consumer of the grain (UCP cart response, // legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) sees the // per-line discount/effective totals without re-running the engine at // response time. Mirrors the same shape /promotions/evaluate-with-cart // has returned since the breakdown feature was introduced; deliberately // persisted via the event-log JSON block so we don't have to re-derive // it on every read path (same precedent as TotalPrice/TotalDiscount). EvaluatedItems []EvaluatedItem `json:"evaluatedItems,omitempty"` Processing bool `json:"processing"` //PaymentInProgress uint16 `json:"paymentInProgress"` OrderReference string `json:"orderReference,omitempty"` Vouchers []*Voucher `json:"vouchers,omitempty"` AppliedPromotions []AppliedPromotion `json:"appliedPromotions,omitempty"` Notifications []CartNotification `json:"cartNotification,omitempty"` SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"` // Email is the cart's recovery contact address. Set explicitly via the // SetRecoveryContact mutation (independent of the linked profile's email), // so abandoned-cart recovery can fire before login. Empty string = no email. Email string `json:"email,omitempty"` // PushTokens holds every push delivery target the shopper attached to this // cart. Empty list = no push. PUT-style replaced by SetRecoveryContact. PushTokens []PushToken `json:"pushTokens,omitempty"` //CheckoutOrderId string `json:"checkoutOrderId,omitempty"` CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"` //CheckoutCountry string `json:"checkoutCountry,omitempty"` } type Voucher struct { Code string `json:"code"` Applied bool `json:"applied"` Rules []string `json:"rules"` Description string `json:"description,omitempty"` Id uint32 `json:"id"` Value int64 `json:"value"` BypassedByPromotions bool `json:"-"` } func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) { // No rules -> applies to entire cart if len(v.Rules) == 0 { return cart.Items, true } // Build evaluation context once ctx := voucher.EvalContext{ Items: make([]voucher.Item, 0, len(cart.Items)), CartTotalInc: 0, } if cart.TotalPrice != nil { ctx.CartTotalInc = cart.TotalPrice.IncVat } for _, it := range cart.Items { category := "" if it.Meta != nil { category = it.Meta.Category } ctx.Items = append(ctx.Items, voucher.Item{ Sku: it.Sku, Category: category, UnitPrice: it.Price.IncVat, }) } // All voucher rules must pass (logical AND) for _, expr := range v.Rules { if expr == "" { // Empty condition treated as pass (acts like a comment / placeholder) continue } rs, err := voucher.ParseRules(expr) if err != nil { // Fail closed on parse error return nil, false } if !rs.Applies(ctx) { return nil, false } } return cart.Items, true } func NewCartGrain(id uint64, ts time.Time) *CartGrain { return &CartGrain{ lastItemId: 0, lastVoucherId: 0, lastAccess: ts, lastChange: ts, TotalDiscount: NewPrice(), Vouchers: []*Voucher{}, Id: CartId(id), Items: []*CartItem{}, TotalPrice: NewPrice(), SubscriptionDetails: make(map[string]*SubscriptionDetails), } } func (c *CartGrain) GetId() uint64 { return uint64(c.Id) } func (c *CartGrain) GetLastChange() time.Time { return c.lastChange } func (c *CartGrain) GetLastAccess() time.Time { return c.lastAccess } func (c *CartGrain) GetCurrentState() (*CartGrain, error) { c.lastAccess = time.Now() return c, nil } func (c *CartGrain) HandleInventoryChange(change inventory.InventoryChange) { for _, item := range c.Items { l := "se" if item.StoreId != nil { l = *item.StoreId } if item.Sku == change.SKU && change.StockLocationID == l { item.Stock = uint16(change.Value) break } } } func (c *CartGrain) GetState() ([]byte, error) { return json.Marshal(c) } func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) { c.mu.RLock() defer c.mu.RUnlock() for _, item := range c.Items { if item.Sku == sku { return item, true } } return nil, false } func (c *CartGrain) UpdateTotals() { c.TotalPrice = NewPrice() c.TotalDiscount = NewPrice() c.AppliedPromotions = nil for _, item := range c.Items { rowTotal := MultiplyPrice(item.Price, int64(item.Quantity)) if item.OrgPrice != nil { diff := NewPrice() diff.Add(*item.OrgPrice) diff.Subtract(item.Price) diff.Multiply(int64(item.Quantity)) //rowTotal.Subtract(*diff) item.Discount = diff if diff.IncVat > 0 { c.TotalDiscount.Add(*diff) } } item.TotalPrice = *rowTotal c.TotalPrice.Add(*rowTotal) } if c.Type == cart_messages.CartType_WISHLIST || c.Type == cart_messages.CartType_OFFER { return } for _, voucher := range c.Vouchers { _, ok := voucher.AppliesTo(c) voucher.Applied = false if ok { if voucher.BypassedByPromotions { voucher.Applied = true continue } value := NewPriceFromIncVat(voucher.Value, 2500) // 25% in basis points if c.TotalPrice.IncVat <= value.IncVat { // don't apply discounts to more than the total price continue } voucher.Applied = true c.TotalDiscount.Add(*value) c.TotalPrice.Subtract(*value) } } }