366 lines
12 KiB
Go
366 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
}
|