350 lines
10 KiB
Go
350 lines
10 KiB
Go
package cart
|
|
|
|
import (
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
|
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
|
)
|
|
|
|
// 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"`
|
|
SellerId string `json:"sellerId,omitempty"`
|
|
SellerName string `json:"sellerName,omitempty"`
|
|
Image string `json:"image,omitempty"`
|
|
Outlet *string `json:"outlet,omitempty"`
|
|
Cgm string `json:"cgm,omitempty"` // Customer Group Membership
|
|
}
|
|
|
|
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"`
|
|
OrgPrice *Price `json:"orgPrice,omitempty"`
|
|
Tax int
|
|
Stock StockStatus `json:"stock"`
|
|
Quantity int `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"`
|
|
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
|
|
OrderReference string `json:"orderReference,omitempty"`
|
|
IsSubscribed bool `json:"isSubscribed,omitempty"`
|
|
}
|
|
|
|
type CartDelivery struct {
|
|
Id uint32 `json:"id"`
|
|
Provider string `json:"provider"`
|
|
Price Price `json:"price"`
|
|
Items []uint32 `json:"items"`
|
|
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type Marking struct {
|
|
Type uint32 `json:"type"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type CartGrain struct {
|
|
mu sync.RWMutex
|
|
lastItemId uint32
|
|
lastDeliveryId uint32
|
|
lastVoucherId uint32
|
|
lastAccess time.Time
|
|
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
|
userId string
|
|
InventoryReserved bool `json:"inventoryReserved"`
|
|
Id CartId `json:"id"`
|
|
Items []*CartItem `json:"items"`
|
|
TotalPrice *Price `json:"totalPrice"`
|
|
TotalDiscount *Price `json:"totalDiscount"`
|
|
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
|
|
Processing bool `json:"processing"`
|
|
PaymentInProgress bool `json:"paymentInProgress"`
|
|
OrderReference string `json:"orderReference,omitempty"`
|
|
PaymentStatus string `json:"paymentStatus,omitempty"`
|
|
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
|
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
|
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
|
PaymentDeclinedNotices []Notice `json:"paymentDeclinedNotices,omitempty"`
|
|
ConfirmationViewCount int `json:"confirmationViewCount,omitempty"`
|
|
ConfirmationLastViewedAt time.Time `json:"confirmationLastViewedAt,omitempty"`
|
|
CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
|
CheckoutStatus string `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"`
|
|
}
|
|
|
|
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,
|
|
lastDeliveryId: 0,
|
|
lastVoucherId: 0,
|
|
lastAccess: ts,
|
|
lastChange: ts,
|
|
TotalDiscount: NewPrice(),
|
|
Vouchers: []*Voucher{},
|
|
Deliveries: []*CartDelivery{},
|
|
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
|
|
}
|
|
|
|
// Notify handles incoming events, e.g., inventory changes.
|
|
func (c *CartGrain) Notify(event actor.Event) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
// Example: if event is inventory change for a SKU in the cart
|
|
if strings.HasPrefix(event.Topic, "inventory:") {
|
|
sku := strings.TrimPrefix(event.Topic, "inventory:")
|
|
for _, item := range c.Items {
|
|
if item.Sku == sku {
|
|
// Update stock status based on payload, e.g., if payload is bool available
|
|
if available, ok := event.Payload.(bool); ok {
|
|
if available {
|
|
item.Stock = StockStatus(1) // assuming 1 is in stock
|
|
} else {
|
|
item.Stock = StockStatus(0) // out of stock
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *CartGrain) UpdateSubscriptions(pubsub *actor.PubSub) {
|
|
pubsub.UnsubscribeAll(c.GetId())
|
|
skuSet := make(map[string]bool)
|
|
for _, item := range c.Items {
|
|
skuSet[item.Sku] = true
|
|
}
|
|
for sku := range skuSet {
|
|
pubsub.Subscribe("inventory:"+sku, c.GetId())
|
|
}
|
|
}
|
|
|
|
func (c *CartGrain) GetState() ([]byte, error) {
|
|
return json.Marshal(c)
|
|
}
|
|
|
|
func (c *CartGrain) ItemsWithDelivery() []uint32 {
|
|
ret := make([]uint32, 0, len(c.Items))
|
|
for _, item := range c.Items {
|
|
for _, delivery := range c.Deliveries {
|
|
for _, id := range delivery.Items {
|
|
if item.Id == id {
|
|
ret = append(ret, id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func (c *CartGrain) ItemsWithoutDelivery() []uint32 {
|
|
ret := make([]uint32, 0, len(c.Items))
|
|
hasDelivery := c.ItemsWithDelivery()
|
|
for _, item := range c.Items {
|
|
found := slices.Contains(hasDelivery, item.Id)
|
|
|
|
if !found {
|
|
ret = append(ret, item.Id)
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
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) Apply(content proto.Message, isReplay bool) (*CartGrain, error) {
|
|
|
|
// updated, err := ApplyRegistered(c, content)
|
|
// if err != nil {
|
|
// if err == ErrMutationNotRegistered {
|
|
// return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
|
|
// }
|
|
// return nil, err
|
|
// }
|
|
|
|
// // Sliding TTL: update lastChange only for non-replay successful mutations.
|
|
// if updated != nil && !isReplay {
|
|
// c.lastChange = time.Now()
|
|
// c.lastAccess = time.Now()
|
|
// go AppendCartEvent(c.Id, content)
|
|
// }
|
|
|
|
// return updated, nil
|
|
// }
|
|
|
|
func (c *CartGrain) UpdateTotals() {
|
|
c.TotalPrice = NewPrice()
|
|
c.TotalDiscount = NewPrice()
|
|
|
|
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)
|
|
|
|
}
|
|
for _, delivery := range c.Deliveries {
|
|
c.TotalPrice.Add(delivery.Price)
|
|
}
|
|
for _, voucher := range c.Vouchers {
|
|
_, ok := voucher.AppliesTo(c)
|
|
voucher.Applied = false
|
|
if ok {
|
|
value := NewPriceFromIncVat(voucher.Value, 25)
|
|
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)
|
|
}
|
|
}
|
|
}
|