package main import ( "bytes" "encoding/json" "fmt" "log" "slices" "sync" messages "git.tornberg.me/go-cart-actor/proto" ) type CartId [16]byte // String returns the cart id as a trimmed UTF-8 string (trailing zero bytes removed). func (id CartId) String() string { n := 0 for n < len(id) && id[n] != 0 { n++ } return string(id[:n]) } // ToCartId converts an arbitrary string to a fixed-size CartId (truncating or padding with zeros). func ToCartId(s string) CartId { var id CartId copy(id[:], []byte(s)) return id } func (id CartId) MarshalJSON() ([]byte, error) { return json.Marshal(id.String()) } func (id *CartId) UnmarshalJSON(data []byte) error { var str string err := json.Unmarshal(data, &str) if err != nil { return err } copy(id[:], []byte(str)) return nil } type StockStatus int const ( OutOfStock StockStatus = 0 LowStock StockStatus = 1 InStock StockStatus = 2 ) type CartItem struct { Id int `json:"id"` ItemId int `json:"itemId,omitempty"` ParentId int `json:"parentId,omitempty"` Sku string `json:"sku"` Name string `json:"name"` Price int64 `json:"price"` TotalPrice int64 `json:"totalPrice"` TotalTax int64 `json:"totalTax"` OrgPrice int64 `json:"orgPrice"` Stock StockStatus `json:"stock"` Quantity int `json:"qty"` Tax int `json:"tax"` TaxRate int `json:"taxRate"` 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"` Disclaimer string `json:"disclaimer,omitempty"` SellerId string `json:"sellerId,omitempty"` SellerName string `json:"sellerName,omitempty"` ArticleType string `json:"type,omitempty"` Image string `json:"image,omitempty"` Outlet *string `json:"outlet,omitempty"` StoreId *string `json:"storeId,omitempty"` } type CartDelivery struct { Id int `json:"id"` Provider string `json:"provider"` Price int64 `json:"price"` Items []int `json:"items"` PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"` } type CartGrain struct { mu sync.RWMutex lastItemId int lastDeliveryId int Id CartId `json:"id"` Items []*CartItem `json:"items"` TotalPrice int64 `json:"totalPrice"` TotalTax int64 `json:"totalTax"` TotalDiscount int64 `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"` } type Grain interface { GetId() CartId Apply(content interface{}, isReplay bool) (*CartGrain, error) GetCurrentState() (*CartGrain, error) } func (c *CartGrain) GetId() CartId { return c.Id } func (c *CartGrain) GetLastChange() int64 { // Legacy event log removed; return 0 to indicate no persisted mutation history. return 0 } func (c *CartGrain) GetCurrentState() (*CartGrain, error) { return c, nil } func getInt(data float64, ok bool) (int, error) { if !ok { return 0, fmt.Errorf("invalid type") } return int(data), nil } func getItemData(sku string, qty int, country string) (*messages.AddItem, error) { item, err := FetchItem(sku, country) if err != nil { return nil, err } orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5]) price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4] if priceErr != nil { return nil, fmt.Errorf("invalid price") } stock := InStock /*item.t if item.StockLevel == "0" || item.StockLevel == "" { stock = OutOfStock } else if item.StockLevel == "5+" { stock = LowStock }*/ articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string) outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string) var outlet *string if ok { outlet = &outletGrade } sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string) sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string) brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string) category, _ := item.GetStringFieldValue(10) //.Fields[10].(string) category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string) category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string) category4, _ := item.GetStringFieldValue(13) //Fields[13].(string) category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string) return &messages.AddItem{ ItemId: int64(item.Id), Quantity: int32(qty), Price: int64(price), OrgPrice: int64(orgPrice), Sku: sku, Name: item.Title, Image: item.Img, Stock: int32(stock), Brand: brand, Category: category, Category2: category2, Category3: category3, Category4: category4, Category5: category5, Tax: 2500, SellerId: sellerId, SellerName: sellerName, ArticleType: articleType, Disclaimer: item.Disclaimer, Country: country, Outlet: outlet, }, nil } func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) { cartItem, err := getItemData(sku, qty, country) if err != nil { return nil, err } cartItem.StoreId = storeId return c.Apply(cartItem, false) } /* Legacy storage (event sourcing) removed in oneof refactor. Kept stub (commented) for potential future reintroduction using proto envelopes. func (c *CartGrain) GetStorageMessage(since int64) []interface{} { return nil } */ func (c *CartGrain) GetState() ([]byte, error) { return json.Marshal(c) } func (c *CartGrain) ItemsWithDelivery() []int { ret := make([]int, 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() []int { ret := make([]int, 0, len(c.Items)) hasDelivery := c.ItemsWithDelivery() for _, item := range c.Items { found := false for _, id := range hasDelivery { if item.Id == id { found = true break } } 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 GetTaxAmount(total int64, tax int) int64 { taxD := 10000 / float64(tax) return int64(float64(total) / float64((1 + taxD))) } func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) { grainMutations.Inc() switch msg := content.(type) { case *messages.SetCartRequest: c.mu.Lock() c.Items = make([]*CartItem, 0, len(msg.Items)) c.mu.Unlock() for _, it := range msg.Items { c.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId) } case *messages.AddRequest: if existing, found := c.FindItemWithSku(msg.Sku); found { existing.Quantity += int(msg.Quantity) c.UpdateTotals() } else { return c.AddItem(msg.Sku, int(msg.Quantity), msg.Country, msg.StoreId) } case *messages.AddItem: if msg.Quantity < 1 { return nil, fmt.Errorf("invalid quantity") } if existing, found := c.FindItemWithSku(msg.Sku); found { existing.Quantity += int(msg.Quantity) c.UpdateTotals() } else { c.mu.Lock() c.lastItemId++ tax := 2500 if msg.Tax > 0 { tax = int(msg.Tax) } taxAmount := GetTaxAmount(msg.Price, tax) c.Items = append(c.Items, &CartItem{ Id: c.lastItemId, ItemId: int(msg.ItemId), Quantity: int(msg.Quantity), Sku: msg.Sku, Name: msg.Name, Price: msg.Price, TotalPrice: msg.Price * int64(msg.Quantity), TotalTax: int64(taxAmount * int64(msg.Quantity)), Image: msg.Image, Stock: StockStatus(msg.Stock), Disclaimer: msg.Disclaimer, Brand: msg.Brand, Category: msg.Category, Category2: msg.Category2, Category3: msg.Category3, Category4: msg.Category4, Category5: msg.Category5, OrgPrice: msg.OrgPrice, ArticleType: msg.ArticleType, Outlet: msg.Outlet, SellerId: msg.SellerId, SellerName: msg.SellerName, Tax: int(taxAmount), TaxRate: tax, StoreId: msg.StoreId, }) c.UpdateTotals() c.mu.Unlock() } case *messages.ChangeQuantity: for i, item := range c.Items { if item.Id == int(msg.Id) { if msg.Quantity <= 0 { c.Items = append(c.Items[:i], c.Items[i+1:]...) } else { item.Quantity = int(msg.Quantity) } break } } c.UpdateTotals() case *messages.RemoveItem: newItems := make([]*CartItem, 0, len(c.Items)) for _, it := range c.Items { if it.Id != int(msg.Id) { newItems = append(newItems, it) } } c.Items = newItems c.UpdateTotals() case *messages.SetDelivery: c.lastDeliveryId++ items := make([]int, 0) withDelivery := c.ItemsWithDelivery() if len(msg.Items) == 0 { items = append(items, c.ItemsWithoutDelivery()...) } else { for _, id := range msg.Items { for _, it := range c.Items { if it.Id == int(id) { if slices.Contains(withDelivery, it.Id) { return nil, fmt.Errorf("item already has delivery") } items = append(items, int(it.Id)) break } } } } if len(items) > 0 { c.Deliveries = append(c.Deliveries, &CartDelivery{ Id: c.lastDeliveryId, Provider: msg.Provider, PickupPoint: msg.PickupPoint, Price: 4900, Items: items, }) c.UpdateTotals() } case *messages.RemoveDelivery: dels := make([]*CartDelivery, 0, len(c.Deliveries)) for _, d := range c.Deliveries { if d.Id == int(msg.Id) { c.TotalPrice -= d.Price } else { dels = append(dels, d) } } c.Deliveries = dels c.UpdateTotals() case *messages.SetPickupPoint: for _, d := range c.Deliveries { if d.Id == int(msg.DeliveryId) { d.PickupPoint = &messages.PickupPoint{ Id: msg.Id, Address: msg.Address, City: msg.City, Zip: msg.Zip, Country: msg.Country, Name: msg.Name, } break } } case *messages.CreateCheckoutOrder: orderLines := make([]*Line, 0, len(c.Items)) c.PaymentInProgress = true c.Processing = true for _, item := range c.Items { orderLines = append(orderLines, &Line{ Type: "physical", Reference: item.Sku, Name: item.Name, Quantity: item.Quantity, UnitPrice: int(item.Price), TaxRate: 2500, QuantityUnit: "st", TotalAmount: int(item.TotalPrice), TotalTaxAmount: int(item.TotalTax), ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", item.Image), }) } for _, line := range c.Deliveries { if line.Price > 0 { orderLines = append(orderLines, &Line{ Type: "shipping_fee", Reference: line.Provider, Name: "Delivery", Quantity: 1, UnitPrice: int(line.Price), TaxRate: 2500, QuantityUnit: "st", TotalAmount: int(line.Price), TotalTaxAmount: int(GetTaxAmount(line.Price, 2500)), }) } } order := CheckoutOrder{ PurchaseCountry: "SE", PurchaseCurrency: "SEK", Locale: "sv-se", OrderAmount: int(c.TotalPrice), OrderTaxAmount: int(c.TotalTax), OrderLines: orderLines, MerchantReference1: c.Id.String(), MerchantURLS: &CheckoutMerchantURLS{ Terms: msg.Terms, Checkout: msg.Checkout, Confirmation: msg.Confirmation, Validation: msg.Validation, Push: msg.Push, }, } orderPayload, err := json.Marshal(order) if err != nil { return nil, err } var klarnaOrder *CheckoutOrder var klarnaError error if c.OrderReference != "" { log.Printf("Updating order id %s", c.OrderReference) klarnaOrder, klarnaError = KlarnaInstance.UpdateOrder(c.OrderReference, bytes.NewReader(orderPayload)) } else { klarnaOrder, klarnaError = KlarnaInstance.CreateOrder(bytes.NewReader(orderPayload)) } if klarnaError != nil { log.Printf("error from klarna: %v", klarnaError) return nil, klarnaError } if c.OrderReference == "" { c.OrderReference = klarnaOrder.ID c.PaymentStatus = klarnaOrder.Status } // This originally returned a FrameWithPayload; now returns the grain state. // The caller (gRPC handler) is responsible for wrapping this. return c, nil case *messages.OrderCreated: c.OrderReference = msg.OrderId c.PaymentStatus = msg.Status c.PaymentInProgress = false default: return nil, fmt.Errorf("unsupported mutation type %T", content) } // (Optional) Append to new storage mechanism here if still required. return c, nil } func (c *CartGrain) UpdateTotals() { c.TotalPrice = 0 c.TotalTax = 0 c.TotalDiscount = 0 for _, item := range c.Items { rowTotal := item.Price * int64(item.Quantity) rowTax := int64(item.Tax) * int64(item.Quantity) item.TotalPrice = rowTotal item.TotalTax = rowTax c.TotalPrice += rowTotal c.TotalTax += rowTax itemDiff := max(0, item.OrgPrice-item.Price) c.TotalDiscount += itemDiff * int64(item.Quantity) } for _, delivery := range c.Deliveries { c.TotalPrice += delivery.Price c.TotalTax += GetTaxAmount(delivery.Price, 2500) } }