split cart and checkout and checkout vs payments

This commit is contained in:
matst80
2025-12-02 20:40:07 +01:00
parent ebd1508294
commit 08327854b7
71 changed files with 4555 additions and 5432 deletions

View File

@@ -2,11 +2,9 @@ package cart
import (
"encoding/json"
"slices"
"sync"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
@@ -55,14 +53,6 @@ type CartItem struct {
ReservationEndTime *time.Time `json:"reservationEndTime,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"`
@@ -84,103 +74,46 @@ type Notice struct {
Code *string `json:"code,omitempty"`
}
type PaymentStatus string
type CartPaymentStatus PaymentStatus
type CartPaymentStatus string
const (
PaymentStatusPending PaymentStatus = "pending"
PaymentStatusFailed PaymentStatus = "failed"
PaymentStatusSuccess PaymentStatus = "success"
CartPaymentStatusPending CartPaymentStatus = "pending"
CartPaymentStatusFailed CartPaymentStatus = "failed"
CartPaymentStatusSuccess CartPaymentStatus = "success"
CartPaymentStatusCancelled CartPaymentStatus = "partial"
)
type CartPayment struct {
PaymentId string `json:"paymentId"`
Status PaymentStatus `json:"status"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Provider string `json:"provider,omitempty"`
Method *string `json:"method,omitempty"`
Events []*PaymentEvent `json:"events,omitempty"`
ProcessorReference *string `json:"processorReference,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
type PaymentEvent struct {
Name string `json:"name"`
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
}
func (p *CartPayment) IsSettled() bool {
if p == nil {
return false
}
switch p.Status {
case PaymentStatusSuccess:
return true
default:
return false
}
}
type Marking struct {
Type uint32 `json:"type"`
Text string `json:"text"`
}
type GiftcardItem struct {
Id uint32 `json:"id"`
Value Price `json:"value"`
DeliveryDate string `json:"deliveryDate"`
Recipient string `json:"recipient"`
RecipientType string `json:"recipientType"`
Message string `json:"message"`
DesignConfig json.RawMessage `json:"designConfig,omitempty"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId uint32
lastDeliveryId uint32
lastVoucherId uint32
lastGiftcardId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
Version uint `json:"version"`
InventoryReserved bool `json:"inventoryReserved"`
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
Giftcards []*GiftcardItem `json:"giftcards,omitempty"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
Processing bool `json:"processing"`
PaymentInProgress uint16 `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
PaymentStatus PaymentStatus `json:"paymentStatus,omitempty"`
PaidInFull bool `json:"paidInFull"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
PaymentDeclinedNotices []Notice `json:"paymentDeclinedNotices,omitempty"`
Payments []*CartPayment `json:"payments,omitempty"`
Confirmation *ConfirmationStatus `json:"confirmation,omitempty"`
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
CheckoutStatus CartPaymentStatus `json:"checkoutStatus,omitempty"`
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
}
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
Currency string `json:"currency"`
Language string `json:"language"`
Version uint `json:"version"`
InventoryReserved bool `json:"inventoryReserved"`
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Processing bool `json:"processing"`
//PaymentInProgress uint16 `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
type ConfirmationStatus struct {
Code *string `json:"code,omitempty"`
ViewCount int `json:"viewCount"`
LastViewedAt time.Time `json:"lastViewedAt"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
}
type Voucher struct {
@@ -243,19 +176,14 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
func NewCartGrain(id uint64, ts time.Time) *CartGrain {
return &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
lastVoucherId: 0,
lastGiftcardId: 0,
lastAccess: ts,
lastChange: ts,
TotalDiscount: NewPrice(),
Vouchers: []*Voucher{},
Deliveries: []*CartDelivery{},
Giftcards: []*GiftcardItem{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
Payments: []*CartPayment{},
SubscriptionDetails: make(map[string]*SubscriptionDetails),
}
}
@@ -294,33 +222,6 @@ 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()
@@ -332,73 +233,6 @@ func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
return nil, false
}
func (c *CartGrain) FindPayment(paymentId string) (*CartPayment, bool) {
if paymentId == "" {
return nil, false
}
for _, payment := range c.Payments {
if payment != nil && payment.PaymentId == paymentId {
return payment, true
}
}
return nil, false
}
func (c *CartGrain) SettledPayments() []*CartPayment {
if len(c.Payments) == 0 {
return nil
}
settled := make([]*CartPayment, 0, len(c.Payments))
for _, payment := range c.Payments {
if payment != nil && payment.IsSettled() {
settled = append(settled, payment)
}
}
if len(settled) == 0 {
return nil
}
return settled
}
func (c *CartGrain) OpenPayments() []*CartPayment {
if len(c.Payments) == 0 {
return nil
}
pending := make([]*CartPayment, 0, len(c.Payments))
for _, payment := range c.Payments {
if payment == nil {
continue
}
if !payment.IsSettled() {
pending = append(pending, payment)
}
}
if len(pending) == 0 {
return nil
}
return pending
}
// 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()
@@ -423,12 +257,7 @@ func (c *CartGrain) UpdateTotals() {
c.TotalPrice.Add(*rowTotal)
}
for _, delivery := range c.Deliveries {
c.TotalPrice.Add(delivery.Price)
}
for _, giftcard := range c.Giftcards {
c.TotalPrice.Add(giftcard.Value)
}
for _, voucher := range c.Vouchers {
_, ok := voucher.AppliesTo(c)
voucher.Applied = false

View File

@@ -5,7 +5,7 @@ import (
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
@@ -70,21 +70,6 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
actor.NewMutation(context.RemoveItem, func() *messages.RemoveItem {
return &messages.RemoveItem{}
}),
actor.NewMutation(context.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{}
}),
@@ -97,12 +82,6 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
actor.NewMutation(UpsertSubscriptionDetails, func() *messages.UpsertSubscriptionDetails {
return &messages.UpsertSubscriptionDetails{}
}),
actor.NewMutation(context.InventoryReserved, func() *messages.InventoryReserved {
return &messages.InventoryReserved{}
}),
actor.NewMutation(PreConditionFailed, func() *messages.PreConditionFailed {
return &messages.PreConditionFailed{}
}),
actor.NewMutation(SetUserId, func() *messages.SetUserId {
return &messages.SetUserId{}
}),
@@ -115,30 +94,6 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
actor.NewMutation(SubscriptionAdded, func() *messages.SubscriptionAdded {
return &messages.SubscriptionAdded{}
}),
actor.NewMutation(PaymentStarted, func() *messages.PaymentStarted {
return &messages.PaymentStarted{}
}),
actor.NewMutation(PaymentCompleted, func() *messages.PaymentCompleted {
return &messages.PaymentCompleted{}
}),
actor.NewMutation(PaymentDeclined, func() *messages.PaymentDeclined {
return &messages.PaymentDeclined{}
}),
actor.NewMutation(PaymentEventHandler, func() *messages.PaymentEvent {
return &messages.PaymentEvent{}
}),
actor.NewMutation(ConfirmationViewed, func() *messages.ConfirmationViewed {
return &messages.ConfirmationViewed{}
}),
actor.NewMutation(CreateCheckoutOrder, func() *messages.CreateCheckoutOrder {
return &messages.CreateCheckoutOrder{}
}),
actor.NewMutation(AddGiftcard, func() *messages.AddGiftcard {
return &messages.AddGiftcard{}
}),
actor.NewMutation(RemoveGiftcard, func() *messages.RemoveGiftcard {
return &messages.RemoveGiftcard{}
}),
)
return reg

View File

@@ -1,48 +0,0 @@
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 != 500 {
t.Fatalf("TotalDiscount expected 500 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)
}
}

View File

@@ -1,44 +0,0 @@
package cart
import (
"encoding/json"
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"google.golang.org/protobuf/proto"
)
func AddGiftcard(grain *CartGrain, req *messages.AddGiftcard) error {
if req.Giftcard == nil {
return fmt.Errorf("giftcard cannot be nil")
}
if req.Giftcard.Value <= 0 {
return fmt.Errorf("giftcard value must be positive")
}
if grain.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
grain.lastGiftcardId++
designConfig := json.RawMessage{}
if req.Giftcard.DesignConfig != nil {
// Convert Any to RawMessage
data, err := proto.Marshal(req.Giftcard.DesignConfig)
if err != nil {
return fmt.Errorf("failed to marshal designConfig: %w", err)
}
designConfig = data
}
value := NewPriceFromIncVat(req.Giftcard.Value, 25) // Assuming 25% tax; adjust as needed
item := &GiftcardItem{
Id: grain.lastGiftcardId,
Value: *value,
DeliveryDate: req.Giftcard.DeliveryDate,
Recipient: req.Giftcard.Recipient,
RecipientType: req.Giftcard.RecipientType,
Message: req.Giftcard.Message,
DesignConfig: designConfig,
}
grain.Giftcards = append(grain.Giftcards, item)
grain.UpdateTotals()
return nil
}

View File

@@ -7,7 +7,7 @@ import (
"log"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -26,7 +26,7 @@ import (
// must keep this handler in sync.
var ErrPaymentInProgress = errors.New("payment in progress")
func (c *CartMutationContext) AddItem(g *CartGrain, m *messages.AddItem) error {
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
ctx := context.Background()
if m == nil {
return fmt.Errorf("AddItem: nil payload")
@@ -34,9 +34,6 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *messages.AddItem) error {
if m.Quantity < 1 {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
if g.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
// Merge with any existing item having same SKU and matching StoreId (including both nil).
for _, existing := range g.Items {

View File

@@ -4,7 +4,7 @@ import (
"slices"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
@@ -15,7 +15,7 @@ func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
StatusCode: 400,
}
}
if g.PaymentInProgress > 0 {
if g.CheckoutStatus != nil {
return ErrPaymentInProgress
}
@@ -45,10 +45,6 @@ func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
}
}
if g.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
return v.Code == m.Code
}) {

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"log"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
// mutation_change_quantity.go
@@ -32,9 +32,7 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
if m == nil {
return fmt.Errorf("ChangeQuantity: nil payload")
}
if g.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
ctx := context.Background()
foundIndex := -1

View File

@@ -0,0 +1,26 @@
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
if m == nil {
return fmt.Errorf("ClearCart: nil payload")
}
if g.CheckoutStatus != nil {
return fmt.Errorf("ClearCart: cart is in checkout")
}
// Clear items, vouchers, etc., but keep userId, etc.
g.Items = g.Items[:0]
g.Vouchers = g.Vouchers[:0]
g.Notifications = g.Notifications[:0]
g.OrderReference = ""
g.Processing = false
// g.InventoryReserved = false maybe should release inventory
g.UpdateTotals()
return nil
}

View File

@@ -1,21 +0,0 @@
package cart
import (
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
func ConfirmationViewed(grain *CartGrain, req *messages.ConfirmationViewed) error {
if grain.Confirmation == nil {
grain.Confirmation = &ConfirmationStatus{
ViewCount: 1,
LastViewedAt: time.Now(),
}
} else {
grain.Confirmation.ViewCount++
grain.Confirmation.LastViewedAt = time.Now()
}
return nil
}

View File

@@ -1,21 +0,0 @@
package cart
import (
"errors"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
func CreateCheckoutOrder(grain *CartGrain, req *messages.CreateCheckoutOrder) error {
if len(grain.Items) == 0 {
return errors.New("cannot checkout empty cart")
}
if req.Terms != "accepted" {
return errors.New("terms must be accepted")
}
// Validate other fields as needed
//grain.CheckoutOrderId = uuid.New().String()
grain.CheckoutStatus = "pending"
//grain.CheckoutCountry = req.Country
return nil
}

View File

@@ -1,59 +0,0 @@
package cart
import (
"context"
"fmt"
"time"
messages "git.k6n.net/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 (c *CartMutationContext) 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")
}
ctx := context.Background()
now := time.Now()
for _, item := range g.Items {
if item.ReservationEndTime != nil {
if now.After(*item.ReservationEndTime) {
endTime, err := c.ReserveItem(ctx, g.Id, item.Sku, item.StoreId, item.Quantity)
if err != nil {
return err
}
item.ReservationEndTime = endTime
}
}
}
g.OrderReference = m.OrderId
//g.PaymentStatus = m.Status
//g.PaymentInProgress = m.PaymentInProgress
return nil
}

View File

@@ -1,22 +0,0 @@
package cart
import (
"context"
"log"
"time"
"git.k6n.net/go-cart-actor/pkg/messages"
)
func (c *CartMutationContext) InventoryReserved(g *CartGrain, m *messages.InventoryReserved) error {
for _, item := range g.Items {
if item.ReservationEndTime != nil && item.ReservationEndTime.After(time.Now()) {
err := c.ReleaseItem(context.Background(), g.Id, item.Sku, item.StoreId)
if err != nil {
log.Printf("unable to release item reservation")
}
}
}
g.InventoryReserved = true
return nil
}

View File

@@ -3,7 +3,7 @@ package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {

View File

@@ -1,48 +0,0 @@
package cart
import (
"fmt"
messages "git.k6n.net/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
}

View File

@@ -1,33 +0,0 @@
package cart
import (
"fmt"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
// PaymentStarted registers the beginning of a payment attempt for a cart.
// It either upserts the payment entry (based on paymentId) or creates a new one,
// marks the cart as having an in-progress payment, and recalculates the PaidInFull flag.
func PaymentCompleted(grain *CartGrain, msg *messages.PaymentCompleted) error {
if msg == nil {
return fmt.Errorf("PaymentStarted: nil payload")
}
paymentId := msg.PaymentId
payment, found := grain.FindPayment(paymentId)
if !found {
return fmt.Errorf("PaymentStarted: payment not found")
}
payment.ProcessorReference = msg.ProcessorReference
payment.Status = PaymentStatusSuccess
payment.Amount = msg.Amount
payment.Currency = msg.Currency
payment.CompletedAt = asPointer(time.Now())
// maybe update cart status
grain.PaymentInProgress--
return nil
}

View File

@@ -1,27 +0,0 @@
package cart
import (
"errors"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
func asPointer[T any](value T) *T {
return &value
}
var ErrPaymentNotFound = errors.New("payment not found")
func PaymentDeclined(grain *CartGrain, req *messages.PaymentDeclined) error {
payment, found := grain.FindPayment(req.PaymentId)
if !found {
return ErrPaymentNotFound
}
payment.CompletedAt = asPointer(time.Now())
payment.Status = PaymentStatusFailed
grain.PaymentInProgress--
return nil
}

View File

@@ -1,22 +0,0 @@
package cart
import (
"encoding/json"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
func PaymentEventHandler(grain *CartGrain, req *messages.PaymentEvent) error {
payment, found := grain.FindPayment(req.PaymentId)
if !found {
return ErrPaymentNotFound
}
metaBytes := req.Data.GetValue()
payment.Events = append(payment.Events, &PaymentEvent{
Name: req.Name,
Success: req.Success,
Data: json.RawMessage(metaBytes),
})
return nil
}

View File

@@ -1,86 +0,0 @@
package cart
import (
"fmt"
"strings"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
// PaymentStarted registers the beginning of a payment attempt for a cart.
// It either upserts the payment entry (based on paymentId) or creates a new one,
// marks the cart as having an in-progress payment, and recalculates the PaidInFull flag.
func PaymentStarted(grain *CartGrain, msg *messages.PaymentStarted) error {
if msg == nil {
return fmt.Errorf("PaymentStarted: nil payload")
}
paymentID := strings.TrimSpace(msg.PaymentId)
if paymentID == "" {
return fmt.Errorf("PaymentStarted: missing paymentId")
}
if msg.Amount < 0 {
return fmt.Errorf("PaymentStarted: amount cannot be negative")
}
currency := strings.TrimSpace(msg.Currency)
provider := strings.TrimSpace(msg.Provider)
method := copyOptionalString(msg.Method)
startedAt := time.Now().UTC()
if msg.StartedAt != nil {
startedAt = msg.StartedAt.AsTime()
}
payment, found := grain.FindPayment(paymentID)
if found {
if payment.Status != PaymentStatusPending {
return fmt.Errorf("PaymentStarted: payment already started")
}
if payment.PaymentId != paymentID {
payment.PaymentId = paymentID
}
payment.Status = PaymentStatusPending
payment.Amount = msg.Amount
if currency != "" {
payment.Currency = currency
}
if provider != "" {
payment.Provider = provider
}
if method != nil {
payment.Method = method
}
payment.StartedAt = &startedAt
payment.CompletedAt = nil
payment.ProcessorReference = nil
} else {
grain.Payments = append(grain.Payments, &CartPayment{
PaymentId: paymentID,
Status: PaymentStatusPending,
Amount: msg.Amount,
Currency: currency,
Provider: provider,
Method: method,
StartedAt: &startedAt,
})
}
grain.PaymentInProgress++
grain.PaymentStatus = PaymentStatusPending
return nil
}
func copyOptionalString(src *string) *string {
if src == nil {
return nil
}
trimmed := strings.TrimSpace(*src)
if trimmed == "" {
return nil
}
dst := trimmed
return &dst
}

View File

@@ -1,7 +0,0 @@
package cart
import messages "git.k6n.net/go-cart-actor/pkg/messages"
func PreConditionFailed(g *CartGrain, m *messages.PreConditionFailed) error {
return nil
}

View File

@@ -1,52 +0,0 @@
package cart
import (
"fmt"
messages "git.k6n.net/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")
}
if g.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
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
}

View File

@@ -1,21 +0,0 @@
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
func RemoveGiftcard(grain *CartGrain, req *messages.RemoveGiftcard) error {
if grain.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
for i, item := range grain.Giftcards {
if item.Id == req.Id {
grain.Giftcards = append(grain.Giftcards[:i], grain.Giftcards[i+1:]...)
grain.UpdateTotals()
return nil
}
}
return fmt.Errorf("giftcard with ID %d not found", req.Id)
}

View File

@@ -6,7 +6,7 @@ import (
"log"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
// mutation_remove_item.go
@@ -29,9 +29,7 @@ func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) e
if m == nil {
return fmt.Errorf("RemoveItem: nil payload")
}
if g.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
targetID := uint32(m.Id)
index := -1

View File

@@ -3,7 +3,7 @@ package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {

View File

@@ -1,99 +0,0 @@
package cart
import (
"fmt"
"slices"
messages "git.k6n.net/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")
}
if g.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
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
}

View File

@@ -1,65 +0,0 @@
package cart
import (
"fmt"
messages "git.k6n.net/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")
}
if g.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
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
}

View File

@@ -3,7 +3,7 @@ package cart
import (
"errors"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func SetUserId(grain *CartGrain, req *messages.SetUserId) error {

View File

@@ -3,13 +3,11 @@ package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
if grain.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
for i, item := range grain.Items {
if item.Id == req.ItemId {
grain.Items[i].SubscriptionDetailsId = req.DetailsId

View File

@@ -1,809 +0,0 @@
package cart
import (
"context"
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/pkg/messages"
)
// ----------------------
// Helper constructors
// ----------------------
func newTestGrain() *CartGrain {
return NewCartGrain(123, time.Now())
}
type MockReservationService struct {
}
func (m *MockReservationService) ReserveForCart(ctx context.Context, req inventory.CartReserveRequest) error {
return nil
}
func (m *MockReservationService) ReleaseForCart(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID, cartID inventory.CartID) error {
return nil
}
func (m *MockReservationService) GetAvailableInventory(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID) (int64, error) {
return 1000, nil
}
func (m *MockReservationService) GetReservationExpiry(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID, cartID inventory.CartID) (time.Time, error) {
return time.Time{}, nil
}
func (m *MockReservationService) GetReservationStatus(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID, cartID inventory.CartID) (*inventory.ReservationStatus, error) {
return nil, nil
}
func (m *MockReservationService) GetReservationSummary(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID) (*inventory.ReservationSummary, error) {
return nil, nil
}
func newRegistry() actor.MutationRegistry {
cartCtx := &CartMutationContext{
reservationService: &MockReservationService{},
}
return NewCartMultationRegistry(cartCtx)
}
func msgAddItem(sku string, price int64, qty int32, storePtr *string) *messages.AddItem {
return &messages.AddItem{
Sku: sku,
Price: price,
Quantity: qty,
// Tax left 0 -> handler uses default 25%
StoreId: storePtr,
}
}
func msgChangeQty(id uint32, qty int32) *messages.ChangeQuantity {
return &messages.ChangeQuantity{Id: id, Quantity: qty}
}
func msgRemoveItem(id uint32) *messages.RemoveItem {
return &messages.RemoveItem{Id: id}
}
func msgSetDelivery(provider string, items ...uint32) *messages.SetDelivery {
uitems := make([]uint32, len(items))
copy(uitems, items)
return &messages.SetDelivery{Provider: provider, Items: uitems}
}
func msgSetPickupPoint(deliveryId uint32, id string) *messages.SetPickupPoint {
return &messages.SetPickupPoint{
DeliveryId: deliveryId,
Id: id,
Name: ptr("Pickup"),
Address: ptr("Street 1"),
City: ptr("Town"),
Zip: ptr("12345"),
Country: ptr("SE"),
}
}
func msgClearCart() *messages.ClearCartRequest {
return &messages.ClearCartRequest{}
}
func msgAddVoucher(code string, value int64, rules ...string) *messages.AddVoucher {
return &messages.AddVoucher{Code: code, Value: value, VoucherRules: rules}
}
func msgRemoveVoucher(id uint32) *messages.RemoveVoucher {
return &messages.RemoveVoucher{Id: id}
}
func msgInitializeCheckout(orderId, status string, inProgress bool) *messages.InitializeCheckout {
return &messages.InitializeCheckout{OrderId: orderId, Status: status, PaymentInProgress: inProgress}
}
func msgOrderCreated(orderId, status string) *messages.OrderCreated {
return &messages.OrderCreated{OrderId: orderId, Status: status}
}
func msgSetUserId(userId string) *messages.SetUserId {
return &messages.SetUserId{UserId: userId}
}
func msgLineItemMarking(id uint32, typ uint32, marking string) *messages.LineItemMarking {
return &messages.LineItemMarking{Id: id, Type: typ, Marking: marking}
}
func msgRemoveLineItemMarking(id uint32) *messages.RemoveLineItemMarking {
return &messages.RemoveLineItemMarking{Id: id}
}
func msgSubscriptionAdded(itemId uint32, detailsId, orderRef string) *messages.SubscriptionAdded {
return &messages.SubscriptionAdded{ItemId: itemId, DetailsId: detailsId, OrderReference: orderRef}
}
// func msgPaymentDeclined(message, code string) *messages.PaymentDeclined {
// return &messages.PaymentDeclined{Message: message, Code: &code}
// }
func msgConfirmationViewed() *messages.ConfirmationViewed {
return &messages.ConfirmationViewed{}
}
func msgCreateCheckoutOrder(terms, country string) *messages.CreateCheckoutOrder {
return &messages.CreateCheckoutOrder{Terms: terms, Country: country}
}
func msgAddGiftcard(value int64, deliveryDate, recipient, recipientType, message string, designConfig *anypb.Any) *messages.AddGiftcard {
return &messages.AddGiftcard{
Giftcard: &messages.GiftcardItem{
Value: value,
DeliveryDate: deliveryDate,
Recipient: recipient,
RecipientType: recipientType,
Message: message,
DesignConfig: designConfig,
},
}
}
func msgRemoveGiftcard(id uint32) *messages.RemoveGiftcard {
return &messages.RemoveGiftcard{Id: id}
}
func ptr[T any](v T) *T { return &v }
// ----------------------
// Apply helpers
// ----------------------
func applyOne(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) actor.ApplyResult {
t.Helper()
results, err := reg.Apply(context.Background(), g, msg)
if err != nil {
t.Fatalf("unexpected registry-level error applying %T: %v", msg, err)
}
if len(results) != 1 {
t.Fatalf("expected exactly one ApplyResult, got %d", len(results))
}
return results[0]
}
// Expect success (nil error inside ApplyResult).
func applyOK(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) {
t.Helper()
res := applyOne(t, reg, g, msg)
if res.Error != nil {
t.Fatalf("expected mutation %s (%T) to succeed, got error: %v", res.Type, msg, res.Error)
}
}
// Expect an error matching substring.
func applyErrorContains(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message, substr string) {
t.Helper()
res := applyOne(t, reg, g, msg)
if res.Error == nil {
t.Fatalf("expected error applying %T, got nil", msg)
}
if substr != "" && !strings.Contains(res.Error.Error(), substr) {
t.Fatalf("error mismatch, want substring %q got %q", substr, res.Error.Error())
}
}
// ----------------------
// Tests
// ----------------------
func TestMutationRegistryCoverage(t *testing.T) {
reg := newRegistry()
expected := []string{
"AddItem",
"ChangeQuantity",
"RemoveItem",
"InitializeCheckout",
"OrderCreated",
"RemoveDelivery",
"SetDelivery",
"SetPickupPoint",
"ClearCartRequest",
"AddVoucher",
"RemoveVoucher",
"UpsertSubscriptionDetails",
"InventoryReserved",
"PreConditionFailed",
"SetUserId",
"LineItemMarking",
"RemoveLineItemMarking",
"SubscriptionAdded",
"PaymentDeclined",
"ConfirmationViewed",
"CreateCheckoutOrder",
"AddGiftcard",
"RemoveGiftcard",
}
names := reg.(*actor.ProtoMutationRegistry).RegisteredMutations()
for _, want := range expected {
if !slices.Contains(names, want) {
t.Fatalf("registry missing mutation %s; got %v", want, names)
}
}
// Create() by name returns correct concrete type.
for _, name := range expected {
msg, ok := reg.Create(name)
if !ok {
t.Fatalf("Create failed for %s", name)
}
rt := reflect.TypeOf(msg)
if rt.Kind() == reflect.Ptr {
rt = rt.Elem()
}
if rt.Name() != name {
t.Fatalf("Create(%s) returned wrong type %s", name, rt.Name())
}
}
// Unregistered create
if m, ok := reg.Create("DoesNotExist"); ok || m != nil {
t.Fatalf("Create should fail for unknown; got (%T,%v)", m, ok)
}
// GetTypeName sanity
add := &messages.AddItem{}
nm, ok := reg.GetTypeName(add)
if !ok || nm != "AddItem" {
t.Fatalf("GetTypeName failed for AddItem, got (%q,%v)", nm, ok)
}
// Apply unregistered message -> should return error
results, err := reg.Apply(context.Background(), newTestGrain(), &messages.Noop{})
if err == nil {
t.Fatalf("expected error for unregistered mutation")
}
if len(results) != 1 || results[0].Error == nil || results[0].Error != actor.ErrMutationNotRegistered {
t.Fatalf("expected ApplyResult with ErrMutationNotRegistered, got %#v", results)
}
}
func TestAddItemAndMerging(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Merge scenario (same SKU + same store pointer)
add1 := msgAddItem("SKU-1", 1000, 2, nil)
applyOK(t, reg, g, add1)
if len(g.Items) != 1 || g.Items[0].Quantity != 2 {
t.Fatalf("expected first item added; items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
}
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 3, nil)) // should merge
if len(g.Items) != 1 || g.Items[0].Quantity != 5 {
t.Fatalf("expected merge quantity=5 items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
}
// Different store pointer -> new line
store := "S1"
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 1, &store))
if len(g.Items) != 2 {
t.Fatalf("expected second line for different store pointer; items=%d", len(g.Items))
}
// Same store pointer & SKU -> merge with second line
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 4, &store))
if len(g.Items) != 2 || g.Items[1].Quantity != 5 {
t.Fatalf("expected merge on second line; items=%d second.qty=%d", len(g.Items), g.Items[1].Quantity)
}
// Invalid quantity
applyErrorContains(t, reg, g, msgAddItem("BAD", 1000, 0, nil), "invalid quantity")
}
func TestChangeQuantityBehavior(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("A", 1500, 2, nil))
id := g.Items[0].Id
// Increase quantity
applyOK(t, reg, g, msgChangeQty(id, 5))
if g.Items[0].Quantity != 5 {
t.Fatalf("quantity not updated expected=5 got=%d", g.Items[0].Quantity)
}
// Remove item by setting <=0
applyOK(t, reg, g, msgChangeQty(id, 0))
if len(g.Items) != 0 {
t.Fatalf("expected item removed; items=%d", len(g.Items))
}
// Not found
applyErrorContains(t, reg, g, msgChangeQty(9999, 1), "not found")
}
func TestRemoveItemBehavior(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("X", 1200, 1, nil))
id := g.Items[0].Id
applyOK(t, reg, g, msgRemoveItem(id))
if len(g.Items) != 0 {
t.Fatalf("expected item removed; items=%d", len(g.Items))
}
applyErrorContains(t, reg, g, msgRemoveItem(id), "not found")
}
func TestDeliveryMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("D1", 1000, 1, nil))
applyOK(t, reg, g, msgAddItem("D2", 2000, 1, nil))
i1 := g.Items[0].Id
// Explicit items
applyOK(t, reg, g, msgSetDelivery("POSTNORD", i1))
if len(g.Deliveries) != 1 || len(g.Deliveries[0].Items) != 1 || g.Deliveries[0].Items[0] != i1 {
t.Fatalf("delivery not created as expected: %+v", g.Deliveries)
}
// Attempt to attach an already-delivered item
applyErrorContains(t, reg, g, msgSetDelivery("POSTNORD", i1), "already has a delivery")
// Attach remaining item via empty list (auto include items without delivery)
applyOK(t, reg, g, msgSetDelivery("DHL"))
if len(g.Deliveries) != 2 {
t.Fatalf("expected second delivery; deliveries=%d", len(g.Deliveries))
}
// Non-existent item
applyErrorContains(t, reg, g, msgSetDelivery("UPS", 99999), "not found")
// No eligible items left
applyErrorContains(t, reg, g, msgSetDelivery("UPS"), "no eligible items")
// Set pickup point on first delivery
did := g.Deliveries[0].Id
applyOK(t, reg, g, msgSetPickupPoint(did, "PP1"))
if g.Deliveries[0].PickupPoint == nil || g.Deliveries[0].PickupPoint.Id != "PP1" {
t.Fatalf("pickup point not set correctly: %+v", g.Deliveries[0].PickupPoint)
}
// Bad delivery id
applyErrorContains(t, reg, g, msgSetPickupPoint(9999, "PPX"), "delivery id")
// Remove delivery
applyOK(t, reg, g, &messages.RemoveDelivery{Id: did})
if len(g.Deliveries) != 1 || g.Deliveries[0].Id == did {
t.Fatalf("expected first delivery removed, remaining: %+v", g.Deliveries)
}
// Remove delivery not found
applyErrorContains(t, reg, g, &messages.RemoveDelivery{Id: did}, "not found")
}
func TestClearCart(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("X", 1000, 2, nil))
applyOK(t, reg, g, msgSetDelivery("P", g.Items[0].Id))
applyOK(t, reg, g, msgClearCart())
if len(g.Items) != 0 || len(g.Deliveries) != 0 {
t.Fatalf("expected cart cleared; items=%d deliveries=%d", len(g.Items), len(g.Deliveries))
}
}
func TestVoucherMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("VOUCH", 10000, 1, nil))
applyOK(t, reg, g, msgAddVoucher("PROMO", 5000))
if len(g.Vouchers) != 1 {
t.Fatalf("voucher not stored")
}
if g.TotalDiscount.IncVat != 5000 {
t.Fatalf("expected discount 5000 got %d", g.TotalDiscount.IncVat)
}
if g.TotalPrice.IncVat != 5000 {
t.Fatalf("expected total price 5000 got %d", g.TotalPrice.IncVat)
}
// Duplicate voucher code
applyErrorContains(t, reg, g, msgAddVoucher("PROMO", 1000), "already applied")
// Add a large voucher (should not apply because value > total price)
applyOK(t, reg, g, msgAddVoucher("BIG", 100000))
if len(g.Vouchers) != 2 {
t.Fatalf("expected second voucher stored")
}
if g.TotalDiscount.IncVat != 5000 || g.TotalPrice.IncVat != 5000 {
t.Fatalf("large voucher incorrectly applied discount=%d total=%d",
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
}
// Remove existing voucher
firstId := g.Vouchers[0].Id
applyOK(t, reg, g, msgRemoveVoucher(firstId))
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool { return v.Id == firstId }) {
t.Fatalf("voucher id %d not removed", firstId)
}
// After removing PROMO, BIG remains but is not applied (exceeds price)
if g.TotalDiscount.IncVat != 0 || g.TotalPrice.IncVat != 10000 {
t.Fatalf("totals incorrect after removal discount=%d total=%d",
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
}
// Remove not applied
applyErrorContains(t, reg, g, msgRemoveVoucher(firstId), "not applied")
}
// func TestCheckoutMutations(t *testing.T) {
// reg := newRegistry()
// g := newTestGrain()
// applyOK(t, reg, g, msgInitializeCheckout("ORD-1", "PENDING", true))
// if g.OrderReference != "ORD-1" || g.PaymentStatus != "PENDING" || !g.PaymentInProgress {
// t.Fatalf("initialize checkout failed: ref=%s status=%s inProgress=%v",
// g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
// }
// applyOK(t, reg, g, msgOrderCreated("ORD-1", "COMPLETED"))
// if g.OrderReference != "ORD-1" || g.PaymentStatus != "COMPLETED" || g.PaymentInProgress {
// t.Fatalf("order created mutation failed: ref=%s status=%s inProgress=%v",
// g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
// }
// applyErrorContains(t, reg, g, msgInitializeCheckout("", "X", true), "missing orderId")
// applyErrorContains(t, reg, g, msgOrderCreated("", "X"), "missing orderId")
// }
func TestSubscriptionDetailsMutation(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Upsert new (Id == nil)
msgNew := &messages.UpsertSubscriptionDetails{
OfferingCode: "OFF1",
SigningType: "TYPE1",
}
applyOK(t, reg, g, msgNew)
if len(g.SubscriptionDetails) != 1 {
t.Fatalf("expected one subscription detail; got=%d", len(g.SubscriptionDetails))
}
// Capture created id
var createdId string
for k := range g.SubscriptionDetails {
createdId = k
}
// Update existing
msgUpdate := &messages.UpsertSubscriptionDetails{
Id: &createdId,
OfferingCode: "OFF2",
SigningType: "TYPE2",
}
applyOK(t, reg, g, msgUpdate)
if g.SubscriptionDetails[createdId].OfferingCode != "OFF2" ||
g.SubscriptionDetails[createdId].SigningType != "TYPE2" {
t.Fatalf("subscription details not updated: %+v", g.SubscriptionDetails[createdId])
}
// Update non-existent
badId := "NON_EXISTENT"
applyErrorContains(t, reg, g, &messages.UpsertSubscriptionDetails{Id: &badId}, "not found")
// Nil mutation should be ignored and produce zero results.
resultsNil, errNil := reg.Apply(context.Background(), g, (*messages.UpsertSubscriptionDetails)(nil))
if errNil != nil {
t.Fatalf("unexpected error for nil mutation element: %v", errNil)
}
if len(resultsNil) != 0 {
t.Fatalf("expected zero results for nil mutation, got %d", len(resultsNil))
}
}
// Ensure registry Apply handles nil grain and nil message defensive errors consistently.
func TestRegistryDefensiveErrors(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Nil grain
results, err := reg.Apply(context.Background(), nil, &messages.AddItem{})
if err == nil {
t.Fatalf("expected error for nil grain")
}
if len(results) != 0 {
t.Fatalf("expected no results for nil grain")
}
// Nil message slice
results, _ = reg.Apply(context.Background(), g, nil)
if len(results) != 0 {
t.Fatalf("expected no results when message slice nil")
}
}
type SubscriptionDetailsRequest struct {
Id *string `json:"id,omitempty"`
OfferingCode string `json:"offeringCode,omitempty"`
SigningType string `json:"signingType,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
return &messages.UpsertSubscriptionDetails{
Id: sd.Id,
OfferingCode: sd.OfferingCode,
SigningType: sd.SigningType,
Data: &anypb.Any{Value: sd.Data},
}
}
func TestSubscriptionDetailsJSONValidation(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Valid JSON on create
jsonStr := `{"offeringCode": "OFFJSON", "signingType": "TYPEJSON", "data": {"value":"test","a":1}}`
var validCreate SubscriptionDetailsRequest
if err := json.Unmarshal([]byte(jsonStr), &validCreate); err != nil {
t.Fatal(err)
}
applyOK(t, reg, g, validCreate.ToMessage())
if len(g.SubscriptionDetails) != 1 {
t.Fatalf("expected one subscription detail after valid create, got %d", len(g.SubscriptionDetails))
}
var id string
for k := range g.SubscriptionDetails {
id = k
}
if string(g.SubscriptionDetails[id].Meta) != `{"value":"test","a":1}` {
t.Fatalf("expected meta stored as valid json, got %s", string(g.SubscriptionDetails[id].Meta))
}
// Update with valid JSON replaces meta
jsonStr2 := fmt.Sprintf(`{"id": "%s", "data": {"value": "eyJjaGFuZ2VkIjoxMjN9"}}`, id)
var updateValid messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr2), &updateValid); err != nil {
t.Fatal(err)
}
applyOK(t, reg, g, &updateValid)
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
t.Fatalf("expected meta updated to new json, got %s", string(g.SubscriptionDetails[id].Meta))
}
// Invalid JSON on create
jsonStr3 := `{"offeringCode": "BAD", "signingType": "TYPE", "data": {"value": "eyJicm9rZW4iO30="}}`
var invalidCreate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr3), &invalidCreate); err != nil {
t.Fatal(err)
}
res := applyOne(t, reg, g, &invalidCreate)
if res.Error == nil || !strings.Contains(res.Error.Error(), "invalid json") {
t.Fatalf("expected invalid json error on create, got %v", res.Error)
}
// Invalid JSON on update
jsonStr4 := fmt.Sprintf(`{"id": "%s", "data": {"value": "e29vcHM="}}`, id)
var badUpdate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr4), &badUpdate); err != nil {
t.Fatal(err)
}
res2 := applyOne(t, reg, g, &badUpdate)
if res2.Error == nil || !strings.Contains(res2.Error.Error(), "invalid json") {
t.Fatalf("expected invalid json error on update, got %v", res2.Error)
}
// Empty Data should not overwrite existing meta
jsonStr5 := fmt.Sprintf(`{"id": "%s"}`, id)
var emptyUpdate messages.UpsertSubscriptionDetails
if err := json.Unmarshal([]byte(jsonStr5), &emptyUpdate); err != nil {
t.Fatal(err)
}
applyOK(t, reg, g, &emptyUpdate)
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
t.Fatalf("empty update should not change meta, got %s", string(g.SubscriptionDetails[id].Meta))
}
}
func TestSetUserId(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgSetUserId("user123"))
if g.userId != "user123" {
t.Fatalf("expected userId=user123, got %s", g.userId)
}
applyErrorContains(t, reg, g, msgSetUserId(""), "cannot be empty")
}
func TestLineItemMarking(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("MARK", 1000, 1, nil))
id := g.Items[0].Id
applyOK(t, reg, g, msgLineItemMarking(id, 1, "Gift message"))
if g.Items[0].Marking == nil || g.Items[0].Marking.Type != 1 || g.Items[0].Marking.Text != "Gift message" {
t.Fatalf("marking not set correctly: %+v", g.Items[0].Marking)
}
applyErrorContains(t, reg, g, msgLineItemMarking(9999, 2, "Test"), "not found")
}
func TestRemoveLineItemMarking(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("REMOVE", 1000, 1, nil))
id := g.Items[0].Id
// First set a marking
applyOK(t, reg, g, msgLineItemMarking(id, 1, "Test marking"))
if g.Items[0].Marking == nil || g.Items[0].Marking.Text != "Test marking" {
t.Fatalf("marking not set")
}
// Now remove it
applyOK(t, reg, g, msgRemoveLineItemMarking(id))
if g.Items[0].Marking != nil {
t.Fatalf("marking not removed")
}
applyErrorContains(t, reg, g, msgRemoveLineItemMarking(9999), "not found")
}
func TestSubscriptionAdded(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("SUB", 1000, 1, nil))
id := g.Items[0].Id
applyOK(t, reg, g, msgSubscriptionAdded(id, "det123", "ord456"))
if g.Items[0].SubscriptionDetailsId != "det123" || g.Items[0].OrderReference != "ord456" || !g.Items[0].IsSubscribed {
t.Fatalf("subscription not added: detailsId=%s orderRef=%s isSubscribed=%v",
g.Items[0].SubscriptionDetailsId, g.Items[0].OrderReference, g.Items[0].IsSubscribed)
}
applyErrorContains(t, reg, g, msgSubscriptionAdded(9999, "", ""), "not found")
}
// func TestPaymentDeclined(t *testing.T) {
// reg := newRegistry()
// g := newTestGrain()
// applyOK(t, reg, g, msgPaymentDeclined("Payment failed due to insufficient funds", "INSUFFICIENT_FUNDS"))
// if g.PaymentStatus != "declined" || g.CheckoutOrderId != "" {
// t.Fatalf("payment declined not handled: status=%s checkoutId=%s", g.PaymentStatus, g.CheckoutOrderId)
// }
// if len(g.PaymentDeclinedNotices) != 1 {
// t.Fatalf("expected 1 notice, got %d", len(g.PaymentDeclinedNotices))
// }
// notice := g.PaymentDeclinedNotices[0]
// if notice.Message != "Payment failed due to insufficient funds" {
// t.Fatalf("notice message not set correctly: %s", notice.Message)
// }
// if notice.Code == nil || *notice.Code != "INSUFFICIENT_FUNDS" {
// t.Fatalf("notice code not set correctly: %v", notice.Code)
// }
// if notice.Timestamp.IsZero() {
// t.Fatalf("notice timestamp not set")
// }
// }
func TestConfirmationViewed(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Initial state
if g.Confirmation != nil {
t.Fatalf("confirmation should be nil, got %v", g.Confirmation)
}
// First view
applyOK(t, reg, g, msgConfirmationViewed())
if g.Confirmation.ViewCount != 1 {
t.Fatalf("view count should be 1, got %d", g.Confirmation.ViewCount)
}
if g.Confirmation.LastViewedAt.IsZero() {
t.Fatalf("ConfirmationLastViewedAt not set")
}
firstTime := g.Confirmation.LastViewedAt
// Second view
applyOK(t, reg, g, msgConfirmationViewed())
if g.Confirmation.ViewCount != 2 {
t.Fatalf("view count should be 2, got %d", g.Confirmation.ViewCount)
}
if g.Confirmation.LastViewedAt == firstTime {
t.Fatalf("ConfirmationLastViewedAt should have updated")
}
}
func TestCreateCheckoutOrder(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("CHECKOUT", 1000, 1, nil))
applyOK(t, reg, g, msgCreateCheckoutOrder("accepted", "SE"))
// if g.CheckoutOrderId == "" || g.CheckoutStatus != "pending" || g.CheckoutCountry != "SE" {
// t.Fatalf("checkout order not created: id=%s status=%s country=%s",
// g.CheckoutOrderId, g.CheckoutStatus, g.CheckoutCountry)
// }
// Empty cart
g2 := newTestGrain()
applyErrorContains(t, reg, g2, msgCreateCheckoutOrder("accepted", ""), "empty cart")
// Terms not accepted
applyErrorContains(t, reg, g, msgCreateCheckoutOrder("no", ""), "terms must be accepted")
}
func TestAddGiftcard(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
designConfig, _ := anypb.New(&messages.AddItem{}) // example
applyOK(t, reg, g, msgAddGiftcard(5000, "2023-12-25", "John", "email", "Happy Birthday!", designConfig))
if len(g.Giftcards) != 1 {
t.Fatalf("expected 1 giftcard, got %d", len(g.Giftcards))
}
gc := g.Giftcards[0]
if gc.Value.IncVat != 5000 || gc.DeliveryDate != "2023-12-25" || gc.Recipient != "John" || gc.RecipientType != "email" || gc.Message != "Happy Birthday!" {
t.Fatalf("giftcard not set correctly: %+v", gc)
}
if g.TotalPrice.IncVat != 5000 {
t.Fatalf("total price not updated, got %d", g.TotalPrice.IncVat)
}
// Test invalid value
applyErrorContains(t, reg, g, msgAddGiftcard(0, "", "", "", "", nil), "must be positive")
}
func TestRemoveGiftcard(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddGiftcard(1000, "2023-01-01", "Jane", "sms", "Cheers!", nil))
id := g.Giftcards[0].Id
applyOK(t, reg, g, msgRemoveGiftcard(id))
if len(g.Giftcards) != 0 {
t.Fatalf("giftcard not removed")
}
if g.TotalPrice.IncVat != 0 {
t.Fatalf("total price not updated after removal, got %d", g.TotalPrice.IncVat)
}
applyErrorContains(t, reg, g, msgRemoveGiftcard(id), "not found")
}

View File

@@ -4,16 +4,14 @@ import (
"encoding/json"
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
if m == nil {
return nil
}
if g.PaymentInProgress > 0 {
return ErrPaymentInProgress
}
metaBytes := m.Data.GetValue()
// Create new subscription details when Id is nil.