feature/backoffice (#6)
Co-authored-by: matst80 <mats.tornberg@gmail.com> Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/6 Co-authored-by: Mats Törnberg <mats@tornberg.me> Co-committed-by: Mats Törnberg <mats@tornberg.me>
This commit was merged in pull request #6.
This commit is contained in:
@@ -10,8 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"github.com/matst80/slask-finder/pkg/messaging"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type QueueEvent struct {
|
||||
@@ -31,46 +29,6 @@ type LogStorage[V any] interface {
|
||||
AppendMutations(id uint64, msg ...proto.Message) error
|
||||
}
|
||||
|
||||
type LogListener interface {
|
||||
AppendMutations(id uint64, msg ...ApplyResult)
|
||||
}
|
||||
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection) *AmqpListener {
|
||||
return &AmqpListener{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) DefineTopics() {
|
||||
ch, err := l.conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type CartEvent struct {
|
||||
Id uint64 `json:"id"`
|
||||
Mutations []ApplyResult `json:"mutations"`
|
||||
}
|
||||
|
||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||
err := messaging.SendChange(l.conn, "cart", "mutation", &CartEvent{
|
||||
Id: id,
|
||||
Mutations: msg,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to send mutation event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
||||
return &DiskStorage[V]{
|
||||
StateStorage: NewState(registry),
|
||||
|
||||
47
pkg/actor/log_listerner.go
Normal file
47
pkg/actor/log_listerner.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/matst80/slask-finder/pkg/messaging"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type LogListener interface {
|
||||
AppendMutations(id uint64, msg ...ApplyResult)
|
||||
}
|
||||
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{
|
||||
conn: conn,
|
||||
transformer: transformer,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) DefineTopics() {
|
||||
ch, err := l.conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||
data, err := l.transformer(id, msg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to transform mutation event: %v", err)
|
||||
return
|
||||
}
|
||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send mutation event: %v", err)
|
||||
}
|
||||
}
|
||||
271
pkg/cart/cart-grain.go
Normal file
271
pkg/cart/cart-grain.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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 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
|
||||
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"`
|
||||
}
|
||||
|
||||
type Voucher struct {
|
||||
Code string `json:"code"`
|
||||
Rules []*messages.VoucherRule `json:"rules"`
|
||||
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 _, rule := range v.Rules {
|
||||
expr := rule.GetCondition()
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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) 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))
|
||||
|
||||
item.TotalPrice = *rowTotal
|
||||
|
||||
c.TotalPrice.Add(*rowTotal)
|
||||
|
||||
if item.OrgPrice != nil {
|
||||
diff := NewPrice()
|
||||
diff.Add(*item.OrgPrice)
|
||||
diff.Subtract(item.Price)
|
||||
if diff.IncVat > 0 {
|
||||
c.TotalDiscount.Add(*diff)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
for _, delivery := range c.Deliveries {
|
||||
c.TotalPrice.Add(delivery.Price)
|
||||
}
|
||||
for _, voucher := range c.Vouchers {
|
||||
if _, ok := voucher.AppliesTo(c); ok {
|
||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||
if c.TotalPrice.IncVat <= value.IncVat {
|
||||
// don't apply discounts to more than the total price
|
||||
continue
|
||||
}
|
||||
|
||||
c.TotalDiscount.Add(*value)
|
||||
c.TotalPrice.Subtract(*value)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
pkg/cart/cart-mutation-helper.go
Normal file
48
pkg/cart/cart-mutation-helper.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
func NewCartMultationRegistry() actor.MutationRegistry {
|
||||
|
||||
reg := actor.NewMutationRegistry()
|
||||
reg.RegisterMutations(
|
||||
actor.NewMutation(AddItem, func() *messages.AddItem {
|
||||
return &messages.AddItem{}
|
||||
}),
|
||||
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
|
||||
return &messages.ChangeQuantity{}
|
||||
}),
|
||||
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
|
||||
return &messages.RemoveItem{}
|
||||
}),
|
||||
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
|
||||
return &messages.InitializeCheckout{}
|
||||
}),
|
||||
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
|
||||
return &messages.OrderCreated{}
|
||||
}),
|
||||
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
|
||||
return &messages.RemoveDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
|
||||
return &messages.SetDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
|
||||
return &messages.SetPickupPoint{}
|
||||
}),
|
||||
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
|
||||
return &messages.ClearCartRequest{}
|
||||
}),
|
||||
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
|
||||
return &messages.AddVoucher{}
|
||||
}),
|
||||
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
|
||||
return &messages.RemoveVoucher{}
|
||||
}),
|
||||
)
|
||||
return reg
|
||||
|
||||
}
|
||||
48
pkg/cart/cart_grain_totals_test.go
Normal file
48
pkg/cart/cart_grain_totals_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helper to create a cart grain with items and deliveries
|
||||
func newTestCart() *CartGrain {
|
||||
return &CartGrain{Items: []*CartItem{}, Deliveries: []*CartDelivery{}, Vouchers: []*Voucher{}, Notifications: []CartNotification{}}
|
||||
}
|
||||
|
||||
func TestCartGrainUpdateTotalsBasic(t *testing.T) {
|
||||
c := newTestCart()
|
||||
// Item1 price 1250 (ex 1000 vat 250) org price higher -> discount 200 per unit
|
||||
item1Price := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
||||
item1Org := &Price{IncVat: 1500, VatRates: map[float32]int64{25: 300}}
|
||||
item2Price := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
|
||||
c.Items = []*CartItem{
|
||||
{Id: 1, Price: item1Price, OrgPrice: item1Org, Quantity: 2},
|
||||
{Id: 2, Price: item2Price, OrgPrice: &item2Price, Quantity: 1},
|
||||
}
|
||||
deliveryPrice := Price{IncVat: 4900, VatRates: map[float32]int64{25: 980}}
|
||||
c.Deliveries = []*CartDelivery{{Id: 1, Price: deliveryPrice, Items: []uint32{1, 2}}}
|
||||
|
||||
c.UpdateTotals()
|
||||
|
||||
// Expected totals: sum inc vat of items * qty plus delivery
|
||||
// item1 total inc = 1250*2 = 2500
|
||||
// item2 total inc = 2000*1 = 2000
|
||||
// delivery inc = 4900
|
||||
expectedInc := int64(2500 + 2000 + 4900)
|
||||
if c.TotalPrice.IncVat != expectedInc {
|
||||
t.Fatalf("TotalPrice IncVat expected %d got %d", expectedInc, c.TotalPrice.IncVat)
|
||||
}
|
||||
|
||||
// Discount: current implementation computes (OrgPrice - Price) ignoring quantity -> 1500-1250=250
|
||||
if c.TotalDiscount.IncVat != 250 {
|
||||
t.Fatalf("TotalDiscount expected 250 got %d", c.TotalDiscount.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCartGrainUpdateTotalsNoItems(t *testing.T) {
|
||||
c := newTestCart()
|
||||
c.UpdateTotals()
|
||||
if c.TotalPrice.IncVat != 0 || c.TotalDiscount.IncVat != 0 {
|
||||
t.Fatalf("expected zero totals got %+v", c)
|
||||
}
|
||||
}
|
||||
159
pkg/cart/cart_id.go
Normal file
159
pkg/cart/cart_id.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// cart_id.go
|
||||
//
|
||||
// Breaking change:
|
||||
// Unified cart identifier as a raw 64-bit unsigned integer (type CartId uint64).
|
||||
// External textual representation: base62 (0-9 A-Z a-z), shortest possible
|
||||
// encoding for 64 bits (max 11 characters, since 62^11 > 2^64).
|
||||
//
|
||||
// Rationale:
|
||||
// - Replaces legacy fixed [16]byte padded string and transitional CartID wrapper.
|
||||
// - Provides compact, URL/cookie-friendly identifiers.
|
||||
// - O(1) hashing and minimal memory footprint.
|
||||
// - 64 bits of crypto randomness => negligible collision probability at realistic scale.
|
||||
//
|
||||
// Public API:
|
||||
// type CartId uint64
|
||||
// func NewCartId() (CartId, error)
|
||||
// func MustNewCartId() CartId
|
||||
// func ParseCartId(string) (CartId, bool)
|
||||
// func MustParseCartId(string) CartId
|
||||
// (CartId).String() string
|
||||
// (CartId).MarshalJSON() / UnmarshalJSON()
|
||||
//
|
||||
// NOTE:
|
||||
// All legacy helpers (UpgradeLegacyCartId, Fallback hashing, Canonicalize variants,
|
||||
// CartIDToLegacy, LegacyToCartID) have been removed as part of the breaking change.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CartId uint64
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// Reverse lookup (0xFF marks invalid)
|
||||
var base62Rev [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range base62Rev {
|
||||
base62Rev[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(base62Alphabet); i++ {
|
||||
base62Rev[base62Alphabet[i]] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoding of the 64-bit id.
|
||||
func (id CartId) String() string {
|
||||
return encodeBase62(uint64(id))
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the cart id as a JSON string.
|
||||
func (id CartId) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||
func (id *CartId) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, ok := ParseCartId(s)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id: %q", s)
|
||||
}
|
||||
*id = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewCartId generates a new cryptographically random non-zero 64-bit id.
|
||||
func NewCartId() (CartId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("NewCartId: %w", err)
|
||||
}
|
||||
u := (uint64(b[0]) << 56) |
|
||||
(uint64(b[1]) << 48) |
|
||||
(uint64(b[2]) << 40) |
|
||||
(uint64(b[3]) << 32) |
|
||||
(uint64(b[4]) << 24) |
|
||||
(uint64(b[5]) << 16) |
|
||||
(uint64(b[6]) << 8) |
|
||||
uint64(b[7])
|
||||
if u == 0 {
|
||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
||||
return NewCartId()
|
||||
}
|
||||
return CartId(u), nil
|
||||
}
|
||||
|
||||
// MustNewCartId panics if generation fails.
|
||||
func MustNewCartId() CartId {
|
||||
id, err := NewCartId()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ParseCartId parses a base62 string into a CartId.
|
||||
// Returns (0,false) for invalid input.
|
||||
func ParseCartId(s string) (CartId, bool) {
|
||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
||||
// extensions; here we stay strict.
|
||||
if len(s) == 0 || len(s) > 11 {
|
||||
return 0, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return CartId(u), true
|
||||
}
|
||||
|
||||
// MustParseCartId panics on invalid base62 input.
|
||||
func MustParseCartId(s string) CartId {
|
||||
id, ok := ParseCartId(s)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("invalid cart id: %q", s))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
||||
func encodeBase62(u uint64) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [11]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = base62Alphabet[u%62]
|
||||
u /= 62
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts base62 text to uint64.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
185
pkg/cart/cart_id_test.go
Normal file
185
pkg/cart/cart_id_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewCartIdUniqueness generates many ids and checks for collisions.
|
||||
func TestNewCartIdUniqueness(t *testing.T) {
|
||||
const n = 20000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
id, err := NewCartId()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCartId error: %v", err)
|
||||
}
|
||||
s := id.String()
|
||||
if _, exists := seen[s]; exists {
|
||||
t.Fatalf("duplicate id encountered: %s", s)
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
if s == "" {
|
||||
t.Fatalf("empty string representation for id %d", id)
|
||||
}
|
||||
if len(s) > 11 {
|
||||
t.Fatalf("encoded id length exceeds 11 chars: %s (%d)", s, len(s))
|
||||
}
|
||||
if id == 0 {
|
||||
// We force regeneration on zero, extremely unlikely but test guards intent.
|
||||
t.Fatalf("zero id generated (should be regenerated)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCartIdRoundTrip ensures parse -> string -> parse is stable.
|
||||
func TestParseCartIdRoundTrip(t *testing.T) {
|
||||
id := MustNewCartId()
|
||||
txt := id.String()
|
||||
parsed, ok := ParseCartId(txt)
|
||||
if !ok {
|
||||
t.Fatalf("ParseCartId failed for valid text %q", txt)
|
||||
}
|
||||
if parsed != id {
|
||||
t.Fatalf("round trip mismatch: original=%d parsed=%d txt=%s", id, parsed, txt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCartIdInvalid covers invalid inputs.
|
||||
func TestParseCartIdInvalid(t *testing.T) {
|
||||
invalid := []string{
|
||||
"", // empty
|
||||
" ", // space
|
||||
"01234567890abc", // >11 chars
|
||||
"!!!!", // invalid chars
|
||||
"-underscore-", // invalid chars
|
||||
"abc_def", // underscore invalid for base62
|
||||
"0123456789ABCD", // 14 chars
|
||||
}
|
||||
for _, s := range invalid {
|
||||
if _, ok := ParseCartId(s); ok {
|
||||
t.Fatalf("expected parse failure for %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustParseCartIdPanics verifies panic behavior for invalid input.
|
||||
func TestMustParseCartIdPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("expected panic for invalid MustParseCartId input")
|
||||
}
|
||||
}()
|
||||
_ = MustParseCartId("not*base62")
|
||||
}
|
||||
|
||||
// TestJSONMarshalUnmarshalCartId verifies JSON round trip.
|
||||
func TestJSONMarshalUnmarshalCartId(t *testing.T) {
|
||||
id := MustNewCartId()
|
||||
data, err := json.Marshal(struct {
|
||||
Cart CartId `json:"cart"`
|
||||
}{Cart: id})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
var out struct {
|
||||
Cart CartId `json:"cart"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if out.Cart != id {
|
||||
t.Fatalf("JSON round trip mismatch: have %d got %d", id, out.Cart)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBase62LengthBound checks worst-case length (near max uint64).
|
||||
func TestBase62LengthBound(t *testing.T) {
|
||||
// Largest uint64
|
||||
const maxU64 = ^uint64(0)
|
||||
s := encodeBase62(maxU64)
|
||||
if len(s) > 11 {
|
||||
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
|
||||
}
|
||||
dec, ok := decodeBase62(s)
|
||||
if !ok || dec != maxU64 {
|
||||
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
|
||||
func TestZeroEncoding(t *testing.T) {
|
||||
if s := encodeBase62(0); s != "0" {
|
||||
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
|
||||
}
|
||||
v, ok := decodeBase62("0")
|
||||
if !ok || v != 0 {
|
||||
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
|
||||
}
|
||||
if _, ok := ParseCartId("0"); !ok {
|
||||
t.Fatalf("ParseCartId(\"0\") should succeed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSequentialParse ensures sequentially generated ids parse correctly.
|
||||
func TestSequentialParse(t *testing.T) {
|
||||
for i := 0; i < 1000; i++ {
|
||||
id := MustNewCartId()
|
||||
txt := id.String()
|
||||
parsed, ok := ParseCartId(txt)
|
||||
if !ok || parsed != id {
|
||||
t.Fatalf("sequential parse mismatch: idx=%d orig=%d parsed=%d txt=%s", i, id, parsed, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkNewCartId measures generation performance.
|
||||
func BenchmarkNewCartId(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := NewCartId(); err != nil {
|
||||
b.Fatalf("NewCartId error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeBase62 measures encoding performance.
|
||||
func BenchmarkEncodeBase62(b *testing.B) {
|
||||
// Precompute sample values
|
||||
samples := make([]uint64, 1024)
|
||||
for i := range samples {
|
||||
// Spread bits without crypto randomness overhead
|
||||
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sink string
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = encodeBase62(samples[i%len(samples)])
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
|
||||
// BenchmarkDecodeBase62 measures decoding performance.
|
||||
func BenchmarkDecodeBase62(b *testing.B) {
|
||||
encoded := make([]string, 1024)
|
||||
for i := range encoded {
|
||||
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sum uint64
|
||||
for i := 0; i < b.N; i++ {
|
||||
v, ok := decodeBase62(encoded[i%len(encoded)])
|
||||
if !ok {
|
||||
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
|
||||
}
|
||||
sum ^= v
|
||||
}
|
||||
_ = sum
|
||||
}
|
||||
|
||||
// ExampleCartIdString documents usage of CartId string form.
|
||||
func ExampleCartId_string() {
|
||||
id := MustNewCartId()
|
||||
fmt.Println(len(id.String()) <= 11) // outputs true
|
||||
// Output: true
|
||||
}
|
||||
91
pkg/cart/mutation_add_item.go
Normal file
91
pkg/cart/mutation_add_item.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Behavior:
|
||||
// * Validates quantity > 0
|
||||
// * If an item with same SKU exists -> increases quantity
|
||||
// * Else creates a new CartItem with computed tax amounts
|
||||
// * Totals recalculated automatically via WithTotals()
|
||||
//
|
||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||
// must keep this handler in sync.
|
||||
|
||||
func AddItem(g *CartGrain, m *messages.AddItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddItem: nil payload")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Fast path: merge with existing item having same SKU
|
||||
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||
if existing.StoreId == m.StoreId {
|
||||
existing.Quantity += int(m.Quantity)
|
||||
existing.Stock = StockStatus(m.Stock)
|
||||
existing.StoreId = m.StoreId
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.lastItemId++
|
||||
taxRate := float32(25.0)
|
||||
if m.Tax > 0 {
|
||||
taxRate = float32(int(m.Tax) / 100)
|
||||
}
|
||||
|
||||
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
|
||||
|
||||
g.Items = append(g.Items, &CartItem{
|
||||
Id: g.lastItemId,
|
||||
ItemId: uint32(m.ItemId),
|
||||
Quantity: int(m.Quantity),
|
||||
Sku: m.Sku,
|
||||
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,
|
||||
SellerId: m.SellerId,
|
||||
SellerName: m.SellerName,
|
||||
},
|
||||
|
||||
Price: *pricePerItem,
|
||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
||||
|
||||
Stock: StockStatus(m.Stock),
|
||||
Disclaimer: m.Disclaimer,
|
||||
|
||||
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
|
||||
ArticleType: m.ArticleType,
|
||||
|
||||
StoreId: m.StoreId,
|
||||
})
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
|
||||
if orgPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
return NewPriceFromIncVat(orgPrice, taxRate)
|
||||
}
|
||||
64
pkg/cart/mutation_add_voucher.go
Normal file
64
pkg/cart/mutation_add_voucher.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
|
||||
if m == nil {
|
||||
return &actor.MutationError{
|
||||
Message: "RemoveVoucher: nil payload",
|
||||
Code: 1003,
|
||||
StatusCode: 400,
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
|
||||
return v.Id == m.Id
|
||||
}) {
|
||||
return &actor.MutationError{
|
||||
Message: "voucher not applied",
|
||||
Code: 1004,
|
||||
StatusCode: 400,
|
||||
}
|
||||
}
|
||||
|
||||
g.Vouchers = slices.DeleteFunc(g.Vouchers, func(v *Voucher) bool {
|
||||
return v.Id == m.Id
|
||||
})
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
|
||||
if m == nil {
|
||||
return &actor.MutationError{
|
||||
Message: "AddVoucher: nil payload",
|
||||
Code: 1001,
|
||||
StatusCode: 400,
|
||||
}
|
||||
}
|
||||
|
||||
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
|
||||
return v.Code == m.Code
|
||||
}) {
|
||||
return &actor.MutationError{
|
||||
Message: "voucher already applied",
|
||||
Code: 1002,
|
||||
StatusCode: 400,
|
||||
}
|
||||
}
|
||||
|
||||
g.lastVoucherId++
|
||||
g.Vouchers = append(g.Vouchers, &Voucher{
|
||||
Id: g.lastVoucherId,
|
||||
Code: m.Code,
|
||||
Rules: m.VoucherRules,
|
||||
Value: m.Value,
|
||||
})
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
54
pkg/cart/mutation_change_quantity.go
Normal file
54
pkg/cart/mutation_change_quantity.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_change_quantity.go
|
||||
//
|
||||
// Registers the ChangeQuantity mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||
// - If requested quantity <= 0 the line is removed.
|
||||
// - Otherwise the line's Quantity field is updated.
|
||||
// - Totals are recalculated (WithTotals).
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns an error if the item Id is not found.
|
||||
// - Returns an error if payload is nil (defensive).
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
|
||||
// the grain's implicit expectation that higher layers control access.
|
||||
// (If strict locking is required around every mutation, wrap logic in
|
||||
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||
|
||||
func ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||
}
|
||||
|
||||
foundIndex := -1
|
||||
for i, it := range g.Items {
|
||||
if it.Id == uint32(m.Id) {
|
||||
foundIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundIndex == -1 {
|
||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
if m.Quantity <= 0 {
|
||||
// Remove the item
|
||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
g.Items[foundIndex].Quantity = int(m.Quantity)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
44
pkg/cart/mutation_initialize_checkout.go
Normal file
44
pkg/cart/mutation_initialize_checkout.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_initialize_checkout.go
|
||||
//
|
||||
// Registers the InitializeCheckout mutation.
|
||||
// This mutation is invoked AFTER an external Klarna checkout session
|
||||
// has been successfully created or updated. It persists the Klarna
|
||||
// order reference / status and marks the cart as having a payment in progress.
|
||||
//
|
||||
// Behavior:
|
||||
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
|
||||
// - Sets PaymentStatus to the current Klarna status.
|
||||
// - Sets / updates PaymentInProgress flag.
|
||||
// - Does NOT alter pricing or line items (so no totals recalculation).
|
||||
//
|
||||
// Validation:
|
||||
// - Returns an error if payload is nil.
|
||||
// - Returns an error if orderId is empty (integrity guard).
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on upstream mutation serialization for a single grain. If
|
||||
// parallel checkout attempts are possible, add higher-level guards
|
||||
// (e.g. reject if PaymentInProgress already true unless reusing
|
||||
// the same OrderReference).
|
||||
|
||||
func InitializeCheckout(g *CartGrain, m *messages.InitializeCheckout) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("InitializeCheckout: nil payload")
|
||||
}
|
||||
if m.OrderId == "" {
|
||||
return fmt.Errorf("InitializeCheckout: missing orderId")
|
||||
}
|
||||
|
||||
g.OrderReference = m.OrderId
|
||||
g.PaymentStatus = m.Status
|
||||
g.PaymentInProgress = m.PaymentInProgress
|
||||
return nil
|
||||
}
|
||||
48
pkg/cart/mutation_order_created.go
Normal file
48
pkg/cart/mutation_order_created.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_order_created.go
|
||||
//
|
||||
// Registers the OrderCreated mutation.
|
||||
//
|
||||
// This mutation represents the completion (or state transition) of an order
|
||||
// initiated earlier via InitializeCheckout / external Klarna processing.
|
||||
// It finalizes (or updates) the cart's order metadata.
|
||||
//
|
||||
// Behavior:
|
||||
// - Validates payload non-nil and OrderId not empty.
|
||||
// - Sets (or overwrites) OrderReference with the provided OrderId.
|
||||
// - Sets PaymentStatus from payload.Status.
|
||||
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
|
||||
// - Does NOT adjust monetary totals (no WithTotals()).
|
||||
//
|
||||
// Notes / Future Extensions:
|
||||
// - If multiple order completion events can arrive (e.g., retries / webhook
|
||||
// replays), this handler is idempotent: it simply overwrites fields.
|
||||
// - If you need to guard against conflicting order IDs, add a check:
|
||||
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
|
||||
// - Add audit logging or metrics here if required.
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on the higher-level guarantee that Apply() calls are serialized
|
||||
// per grain. If out-of-order events are possible, embed versioning or
|
||||
// timestamps in the mutation and compare before applying changes.
|
||||
|
||||
func OrderCreated(g *CartGrain, m *messages.OrderCreated) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("OrderCreated: nil payload")
|
||||
}
|
||||
if m.OrderId == "" {
|
||||
return fmt.Errorf("OrderCreated: missing orderId")
|
||||
}
|
||||
|
||||
g.OrderReference = m.OrderId
|
||||
g.PaymentStatus = m.Status
|
||||
g.PaymentInProgress = false
|
||||
return nil
|
||||
}
|
||||
49
pkg/cart/mutation_remove_delivery.go
Normal file
49
pkg/cart/mutation_remove_delivery.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_remove_delivery.go
|
||||
//
|
||||
// Registers the RemoveDelivery mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the delivery entry whose Id == payload.Id.
|
||||
// - If not found, returns an error.
|
||||
// - Cart totals are recalculated (WithTotals) after removal.
|
||||
// - Items previously associated with that delivery simply become "without delivery";
|
||||
// subsequent delivery mutations can reassign them.
|
||||
//
|
||||
// Differences vs legacy:
|
||||
// - Legacy logic decremented TotalPrice explicitly before recalculating.
|
||||
// Here we rely solely on UpdateTotals() to recompute from remaining
|
||||
// deliveries and items (simpler / single source of truth).
|
||||
//
|
||||
// Future considerations:
|
||||
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
|
||||
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
|
||||
|
||||
func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("RemoveDelivery: nil payload")
|
||||
}
|
||||
targetID := uint32(m.Id)
|
||||
index := -1
|
||||
for i, d := range g.Deliveries {
|
||||
if d.Id == targetID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("RemoveDelivery: delivery id %d not found", m.Id)
|
||||
}
|
||||
|
||||
// Remove delivery (order not preserved beyond necessity)
|
||||
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
45
pkg/cart/mutation_remove_item.go
Normal file
45
pkg/cart/mutation_remove_item.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_remove_item.go
|
||||
//
|
||||
// Registers the RemoveItem mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the cart line whose local cart line Id == payload.Id
|
||||
// - If no such line exists returns an error
|
||||
// - Recalculates cart totals (WithTotals)
|
||||
//
|
||||
// Notes:
|
||||
// - This removes only the line item; any deliveries referencing the removed
|
||||
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||
// - If multiple lines somehow shared the same Id (should not happen), only
|
||||
// the first match would be removed—data integrity relies on unique line Ids.
|
||||
|
||||
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("RemoveItem: nil payload")
|
||||
}
|
||||
targetID := uint32(m.Id)
|
||||
|
||||
index := -1
|
||||
for i, it := range g.Items {
|
||||
if it.Id == targetID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
96
pkg/cart/mutation_set_delivery.go
Normal file
96
pkg/cart/mutation_set_delivery.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_set_delivery.go
|
||||
//
|
||||
// Registers the SetDelivery mutation.
|
||||
//
|
||||
// Semantics (mirrors legacy switch logic):
|
||||
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||
// - Each referenced cart line must exist.
|
||||
// - None of the referenced items may already belong to a delivery.
|
||||
// - Only those items are associated with the new delivery.
|
||||
// - If payload.Items is empty:
|
||||
// - All items currently without any delivery are associated with the new delivery.
|
||||
// - A new delivery line is created with:
|
||||
// - Auto-incremented delivery ID (cart-local)
|
||||
// - Provider from payload
|
||||
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||
// - Optional PickupPoint copied from payload
|
||||
// - Cart totals are recalculated (WithTotals)
|
||||
//
|
||||
// Error cases:
|
||||
// - Referenced item does not exist
|
||||
// - Referenced item already has a delivery
|
||||
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||
// Item scans are read-only and performed outside the lock for simplicity;
|
||||
// if stricter guarantees are needed, widen the lock section.
|
||||
//
|
||||
// Future extension points:
|
||||
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||
// - Validation of provider codes
|
||||
// - Multi-currency delivery pricing
|
||||
|
||||
func SetDelivery(g *CartGrain, m *messages.SetDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetDelivery: nil payload")
|
||||
}
|
||||
if m.Provider == "" {
|
||||
return fmt.Errorf("SetDelivery: provider is empty")
|
||||
}
|
||||
|
||||
withDelivery := g.ItemsWithDelivery()
|
||||
targetItems := make([]uint32, 0)
|
||||
|
||||
if len(m.Items) == 0 {
|
||||
// Use every item currently without a delivery
|
||||
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
||||
} else {
|
||||
// Validate explicit list
|
||||
for _, id64 := range m.Items {
|
||||
id := uint32(id64)
|
||||
found := false
|
||||
for _, it := range g.Items {
|
||||
if it.Id == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
||||
}
|
||||
if slices.Contains(withDelivery, id) {
|
||||
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
||||
}
|
||||
targetItems = append(targetItems, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(targetItems) == 0 {
|
||||
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
||||
}
|
||||
|
||||
// Append new delivery
|
||||
g.mu.Lock()
|
||||
g.lastDeliveryId++
|
||||
newId := g.lastDeliveryId
|
||||
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||
Id: newId,
|
||||
Provider: m.Provider,
|
||||
PickupPoint: m.PickupPoint,
|
||||
Price: *NewPriceFromIncVat(4900, 25.0),
|
||||
Items: targetItems,
|
||||
})
|
||||
g.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
62
pkg/cart/mutation_set_pickup_point.go
Normal file
62
pkg/cart/mutation_set_pickup_point.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_set_pickup_point.go
|
||||
//
|
||||
// Registers the SetPickupPoint mutation using the generic mutation registry.
|
||||
//
|
||||
// Semantics (mirrors original switch-based implementation):
|
||||
// - Locate the delivery with Id == payload.DeliveryId
|
||||
// - Set (or overwrite) its PickupPoint with the provided data
|
||||
// - Does NOT alter pricing or taxes (so no totals recalculation required)
|
||||
//
|
||||
// Validation / Error Handling:
|
||||
// - If payload is nil -> error
|
||||
// - If DeliveryId not found -> error
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on the existing expectation that higher-level mutation routing
|
||||
// serializes Apply() calls per grain; if stricter guarantees are needed,
|
||||
// a delivery-level lock could be introduced later.
|
||||
//
|
||||
// Future Extensions:
|
||||
// - Validate pickup point fields (country code, zip format, etc.)
|
||||
// - Track history / audit of pickup point changes
|
||||
// - Trigger delivery price adjustments (which would then require WithTotals()).
|
||||
|
||||
func SetPickupPoint(g *CartGrain, m *messages.SetPickupPoint) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetPickupPoint: nil payload")
|
||||
}
|
||||
|
||||
for _, d := range g.Deliveries {
|
||||
if d.Id == uint32(m.DeliveryId) {
|
||||
d.PickupPoint = &messages.PickupPoint{
|
||||
Id: m.Id,
|
||||
Name: m.Name,
|
||||
Address: m.Address,
|
||||
City: m.City,
|
||||
Zip: m.Zip,
|
||||
Country: m.Country,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
||||
}
|
||||
|
||||
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("ClearCart: nil payload")
|
||||
}
|
||||
// maybe check if payment is done?
|
||||
g.Deliveries = g.Deliveries[:0]
|
||||
g.Items = g.Items[:0]
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
131
pkg/cart/price.go
Normal file
131
pkg/cart/price.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func GetTaxAmount(total int64, tax int) int64 {
|
||||
taxD := 10000 / float64(tax)
|
||||
return int64(float64(total) / float64((1 + taxD)))
|
||||
}
|
||||
|
||||
type Price struct {
|
||||
IncVat int64 `json:"incVat"`
|
||||
VatRates map[float32]int64 `json:"vat,omitempty"`
|
||||
}
|
||||
|
||||
func NewPrice() *Price {
|
||||
return &Price{
|
||||
IncVat: 0,
|
||||
VatRates: make(map[float32]int64),
|
||||
}
|
||||
}
|
||||
|
||||
func NewPriceFromIncVat(incVat int64, taxRate float32) *Price {
|
||||
tax := GetTaxAmount(incVat, int(taxRate*100))
|
||||
return &Price{
|
||||
IncVat: incVat,
|
||||
VatRates: map[float32]int64{
|
||||
taxRate: tax,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Price) ValueExVat() int64 {
|
||||
exVat := p.IncVat
|
||||
for _, amount := range p.VatRates {
|
||||
exVat -= amount
|
||||
}
|
||||
return exVat
|
||||
}
|
||||
|
||||
func (p *Price) TotalVat() int64 {
|
||||
total := int64(0)
|
||||
for _, amount := range p.VatRates {
|
||||
total += amount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func MultiplyPrice(p Price, qty int64) *Price {
|
||||
ret := &Price{
|
||||
IncVat: p.IncVat * qty,
|
||||
VatRates: make(map[float32]int64),
|
||||
}
|
||||
for rate, amount := range p.VatRates {
|
||||
ret.VatRates[rate] = amount * qty
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p *Price) Multiply(qty int64) {
|
||||
p.IncVat *= qty
|
||||
for rate, amount := range p.VatRates {
|
||||
p.VatRates[rate] = amount * qty
|
||||
}
|
||||
}
|
||||
|
||||
func (p Price) MarshalJSON() ([]byte, error) {
|
||||
// Build a stable wire format without calling Price.MarshalJSON recursively
|
||||
exVat := p.ValueExVat()
|
||||
var vat map[string]int64
|
||||
if len(p.VatRates) > 0 {
|
||||
vat = make(map[string]int64, len(p.VatRates))
|
||||
for rate, amount := range p.VatRates {
|
||||
// Rely on default formatting that trims trailing zeros for whole numbers
|
||||
// Using %g could output scientific notation for large numbers; float32 rates here are small.
|
||||
key := trimFloat(rate)
|
||||
vat[key] = amount
|
||||
}
|
||||
}
|
||||
type wire struct {
|
||||
ExVat int64 `json:"exVat"`
|
||||
IncVat int64 `json:"incVat"`
|
||||
Vat map[string]int64 `json:"vat,omitempty"`
|
||||
}
|
||||
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
|
||||
}
|
||||
|
||||
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
|
||||
// unnecessary decimals ("25", "12.5").
|
||||
func trimFloat(f float32) string {
|
||||
// Convert via FormatFloat then trim trailing zeros and dot.
|
||||
s := strconv.FormatFloat(float64(f), 'f', -1, 32)
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Price) Add(price Price) {
|
||||
p.IncVat += price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
p.VatRates[rate] += amount
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Price) Subtract(price Price) {
|
||||
p.IncVat -= price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
p.VatRates[rate] -= amount
|
||||
}
|
||||
}
|
||||
|
||||
func SumPrices(prices ...Price) *Price {
|
||||
if len(prices) == 0 {
|
||||
return NewPrice()
|
||||
}
|
||||
|
||||
aggregated := NewPrice()
|
||||
|
||||
for _, price := range prices {
|
||||
aggregated.IncVat += price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
aggregated.VatRates[rate] += amount
|
||||
}
|
||||
}
|
||||
|
||||
if len(aggregated.VatRates) == 0 {
|
||||
aggregated.VatRates = nil
|
||||
}
|
||||
|
||||
return aggregated
|
||||
}
|
||||
Reference in New Issue
Block a user