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

@@ -0,0 +1,184 @@
package checkout
import (
"encoding/json"
"sync"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
)
// CheckoutId is the same as CartId for simplicity
type CheckoutId = cart.CartId
type PickupPoint struct {
DeliveryId uint32 `json:"deliveryId,omitempty"`
Id string `json:"id"`
Name *string `json:"name,omitempty"`
Address *string `json:"address,omitempty"`
City *string `json:"city,omitempty"`
Zip *string `json:"zip,omitempty"`
Country *string `json:"country,omitempty"`
}
type CheckoutDelivery struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price cart.Price `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *PickupPoint `json:"pickupPoint,omitempty"`
}
type PaymentStatus string
type CheckoutPaymentStatus PaymentStatus
const (
PaymentStatusPending PaymentStatus = "pending"
PaymentStatusFailed PaymentStatus = "failed"
PaymentStatusSuccess PaymentStatus = "success"
CheckoutPaymentStatusPending CheckoutPaymentStatus = "pending"
CheckoutPaymentStatusFailed CheckoutPaymentStatus = "failed"
CheckoutPaymentStatusSuccess CheckoutPaymentStatus = "success"
CheckoutPaymentStatusCancelled CheckoutPaymentStatus = "partial"
)
type Payment 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 *Payment) IsSettled() bool {
if p == nil {
return false
}
switch p.Status {
case PaymentStatusSuccess:
return true
default:
return false
}
}
type ConfirmationStatus struct {
Code *string `json:"code,omitempty"`
ViewCount int `json:"viewCount"`
LastViewedAt time.Time `json:"lastViewedAt"`
}
type CheckoutGrain struct {
mu sync.RWMutex
lastDeliveryId uint32
lastGiftcardId uint32
lastAccess time.Time
lastChange time.Time
Version uint32
Id CheckoutId `json:"id"`
CartId cart.CartId `json:"cartId"`
CartVersion uint64 `json:"cartVersion"`
CartState *cart.CartGrain `json:"cartState"` // snapshot of items
CartTotalPrice *cart.Price `json:"cartTotalPrice"`
OrderId *string `json:"orderId"`
Deliveries []*CheckoutDelivery `json:"deliveries,omitempty"`
PaymentInProgress uint16 `json:"paymentInProgress"`
InventoryReserved bool `json:"inventoryReserved"`
Confirmation *ConfirmationStatus `json:"confirmationViewed,omitempty"`
Payments []*Payment `json:"payments,omitempty"`
}
func NewCheckoutGrain(id uint64, cartId cart.CartId, cartVersion uint64, ts time.Time, cartState *cart.CartGrain) *CheckoutGrain {
return &CheckoutGrain{
lastDeliveryId: 0,
lastGiftcardId: 0,
lastAccess: ts,
lastChange: ts,
Id: CheckoutId(id),
CartId: cartId,
CartVersion: cartVersion,
Deliveries: []*CheckoutDelivery{},
Payments: []*Payment{},
CartState: cartState,
CartTotalPrice: cartState.TotalPrice,
}
}
func (c *CheckoutGrain) GetId() uint64 {
return uint64(c.Id)
}
func (c *CheckoutGrain) GetLastChange() time.Time {
return c.lastChange
}
func (c *CheckoutGrain) GetLastAccess() time.Time {
return c.lastAccess
}
func (c *CheckoutGrain) GetCurrentState() (*CheckoutGrain, error) {
c.lastAccess = time.Now()
return c, nil
}
func (c *CheckoutGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CheckoutGrain) FindPayment(paymentId string) (*Payment, 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 *CheckoutGrain) SettledPayments() []*Payment {
if len(c.Payments) == 0 {
return nil
}
settled := make([]*Payment, 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 *CheckoutGrain) OpenPayments() []*Payment {
if len(c.Payments) == 0 {
return nil
}
pending := make([]*Payment, 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
}

View File

@@ -0,0 +1,35 @@
package checkout
import (
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
type CheckoutMutationContext struct {
// Add any services needed, e.g., for delivery calculations, but since inventory is pre-handled, maybe none
}
func NewCheckoutMutationContext() *CheckoutMutationContext {
return &CheckoutMutationContext{}
}
func NewCheckoutMutationRegistry(ctx *CheckoutMutationContext) actor.MutationRegistry {
reg := actor.NewMutationRegistry()
reg.RegisterMutations(
actor.NewMutation(HandleInitializeCheckout, func() *messages.InitializeCheckout { return &messages.InitializeCheckout{} }),
actor.NewMutation(HandlePaymentStarted, func() *messages.PaymentStarted { return &messages.PaymentStarted{} }),
actor.NewMutation(HandlePaymentCompleted, func() *messages.PaymentCompleted { return &messages.PaymentCompleted{} }),
actor.NewMutation(HandlePaymentDeclined, func() *messages.PaymentDeclined { return &messages.PaymentDeclined{} }),
actor.NewMutation(HandlePaymentEvent, func() *messages.PaymentEvent { return &messages.PaymentEvent{} }),
actor.NewMutation(HandleConfirmationViewed, func() *messages.ConfirmationViewed { return &messages.ConfirmationViewed{} }),
//actor.NewMutation(HandleCreateCheckoutOrder, func() *messages.CreateCheckoutOrder { return &messages.CreateCheckoutOrder{} }),
actor.NewMutation(HandleOrderCreated, func() *messages.OrderCreated { return &messages.OrderCreated{} }),
actor.NewMutation(HandleInventoryReserved, func() *messages.InventoryReserved { return &messages.InventoryReserved{} }),
actor.NewMutation(HandleSetDelivery, func() *messages.SetDelivery { return &messages.SetDelivery{} }),
actor.NewMutation(HandleSetPickupPoint, func() *messages.SetPickupPoint { return &messages.SetPickupPoint{} }),
actor.NewMutation(HandleRemoveDelivery, func() *messages.RemoveDelivery { return &messages.RemoveDelivery{} }),
// actor.NewMutation(HandleAddGiftcard, func() *messages.AddGiftcard { return &messages.AddGiftcard{} }),
// actor.NewMutation(HandleRemoveGiftcard, func() *messages.RemoveGiftcard { return &messages.RemoveGiftcard{} }),
)
return reg
}

View File

@@ -0,0 +1,26 @@
package checkout
import (
"fmt"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandleConfirmationViewed(g *CheckoutGrain, m *messages.ConfirmationViewed) error {
if m == nil {
return fmt.Errorf("ConfirmationViewed: nil payload")
}
if g.Confirmation != nil {
g.Confirmation = &ConfirmationStatus{
ViewCount: 1,
LastViewedAt: time.Now(),
}
} else {
g.Confirmation.ViewCount++
g.Confirmation.LastViewedAt = time.Now()
}
return nil
}

View File

@@ -0,0 +1,55 @@
package checkout
import (
"fmt"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func asPickupPoint(p *messages.PickupPoint, deliveryId uint32) *PickupPoint {
if p == nil {
return nil
}
return &PickupPoint{
Id: p.Id,
Name: p.Name,
Address: p.Address,
City: p.City,
Country: p.Country,
Zip: p.Zip,
}
}
// HandleSetDelivery mutation
// HandleSetDelivery mutation
func HandleSetDelivery(g *CheckoutGrain, m *messages.SetDelivery) error {
if m == nil {
return fmt.Errorf("HandleSetDelivery: nil payload")
}
if m.Provider == "" {
return fmt.Errorf("HandleSetDelivery: missing provider")
}
// Check if delivery already exists, update or add
for _, d := range g.Deliveries {
if d.Provider == m.Provider {
// Update existing
d.Items = m.Items
d.PickupPoint = asPickupPoint(m.PickupPoint, d.Id)
return nil
}
}
// Add new delivery
g.lastDeliveryId++
delivery := &CheckoutDelivery{
Id: g.lastDeliveryId,
Provider: m.Provider,
Items: m.Items,
PickupPoint: asPickupPoint(m.PickupPoint, g.lastDeliveryId),
Price: *cart.NewPrice(), // Price might need calculation, but for now zero
}
g.Deliveries = append(g.Deliveries, delivery)
return nil
}

View File

@@ -0,0 +1,44 @@
package checkout
import (
"encoding/json"
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_initialize_checkout.go
//
// Registers the InitializeCheckout mutation.
// This mutation is invoked AFTER an external checkout session
// has been successfully created or updated. It persists the
// order reference / status and marks the checkout as having a payment in progress.
//
// Behavior:
// - Sets OrderId to the order ID.
// - Sets Status to the current status.
// - Sets PaymentInProgress flag.
// - Assumes inventory is already reserved.
//
// Validation:
// - Returns an error if payload is nil.
// - Returns an error if orderId is empty.
func HandleInitializeCheckout(g *CheckoutGrain, m *messages.InitializeCheckout) error {
if m == nil {
return fmt.Errorf("InitializeCheckout: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("InitializeCheckout: missing orderId")
}
if m.CartState != nil {
return fmt.Errorf("InitializeCheckout: checkout already initialized")
}
err := json.Unmarshal(m.CartState.Value, &g.CartState)
if err != nil {
return fmt.Errorf("InitializeCheckout: failed to unmarshal cart state: %w", err)
}
return nil
}

View File

@@ -0,0 +1,16 @@
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandleInventoryReserved(g *CheckoutGrain, m *messages.InventoryReserved) error {
if m == nil {
return fmt.Errorf("HandleInventoryReserved: nil payload")
}
g.InventoryReserved = m.Status == "success"
return nil
}

View File

@@ -0,0 +1,49 @@
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_order_created.go
//
// Registers the OrderCreated mutation.
//
// This mutation represents the completion (or state transition) of an order
// initiated earlier via InitializeCheckout / external processing.
// It finalizes (or updates) the checkout's order metadata.
//
// Behavior:
// - Validates payload non-nil and OrderId not empty.
// - Sets Status from payload.Status.
// - Sets OrderId if not already set.
// - Does NOT adjust monetary totals.
//
// 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.OrderId != "" && g.OrderId != 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 HandleOrderCreated(g *CheckoutGrain, m *messages.OrderCreated) error {
if m == nil {
return fmt.Errorf("HandleOrderCreated: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("OrderCreated: missing orderId")
}
if g.OrderId == nil {
g.OrderId = &m.OrderId
} else if *g.OrderId != m.OrderId {
return fmt.Errorf("OrderCreated: conflicting order ID")
}
return nil
}

View File

@@ -0,0 +1,37 @@
package checkout
import (
"fmt"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// PaymentCompleted registers the completion of a payment for a checkout.
func HandlePaymentCompleted(g *CheckoutGrain, m *messages.PaymentCompleted) error {
if m == nil {
return fmt.Errorf("PaymentCompleted: nil payload")
}
paymentId := m.PaymentId
payment, found := g.FindPayment(paymentId)
if !found {
return fmt.Errorf("PaymentCompleted: payment not found")
}
payment.ProcessorReference = m.ProcessorReference
payment.Status = PaymentStatusSuccess
payment.Amount = m.Amount
payment.Currency = m.Currency
payment.CompletedAt = &time.Time{}
if m.CompletedAt != nil {
*payment.CompletedAt = m.CompletedAt.AsTime()
} else {
*payment.CompletedAt = time.Now()
}
// Update checkout status
g.PaymentInProgress--
return nil
}

View File

@@ -0,0 +1,28 @@
package checkout
import (
"errors"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func asPointer[T any](value T) *T {
return &value
}
var ErrPaymentNotFound = errors.New("payment not found")
func HandlePaymentDeclined(g *CheckoutGrain, m *messages.PaymentDeclined) error {
payment, found := g.FindPayment(m.PaymentId)
if !found {
return ErrPaymentNotFound
}
payment.CompletedAt = asPointer(time.Now())
payment.Status = "failed"
g.PaymentInProgress--
return nil
}

View File

@@ -0,0 +1,20 @@
package checkout
import (
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandlePaymentEvent(g *CheckoutGrain, m *messages.PaymentEvent) error {
payment, found := g.FindPayment(m.PaymentId)
if !found {
return ErrPaymentNotFound
}
metaBytes := m.Data.Value
payment.Events = append(payment.Events, &PaymentEvent{
Name: m.Name,
Success: m.Success,
Data: metaBytes,
})
return nil
}

View File

@@ -0,0 +1,84 @@
package checkout
import (
"fmt"
"strings"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// PaymentStarted registers the beginning of a payment attempt for a checkout.
// It either upserts the payment entry (based on paymentId) or creates a new one,
// marks the checkout as having a payment in progress.
func HandlePaymentStarted(g *CheckoutGrain, m *messages.PaymentStarted) error {
if m == nil {
return fmt.Errorf("PaymentStarted: nil payload")
}
paymentID := strings.TrimSpace(m.PaymentId)
if paymentID == "" {
return fmt.Errorf("PaymentStarted: missing paymentId")
}
if m.Amount < 0 {
return fmt.Errorf("PaymentStarted: amount cannot be negative")
}
currency := strings.TrimSpace(m.Currency)
provider := strings.TrimSpace(m.Provider)
method := copyOptionalString(m.Method)
startedAt := time.Now().UTC()
if m.StartedAt != nil {
startedAt = m.StartedAt.AsTime()
}
payment, found := g.FindPayment(paymentID)
if found {
if payment.Status != "pending" {
return fmt.Errorf("PaymentStarted: payment already started")
}
if payment.PaymentId != paymentID {
payment.PaymentId = paymentID
}
payment.Status = "pending"
payment.Amount = m.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 {
g.PaymentInProgress++
g.Payments = append(g.Payments, &Payment{
PaymentId: paymentID,
Status: "pending",
Amount: m.Amount,
Currency: currency,
Provider: provider,
Method: method,
StartedAt: &startedAt,
})
}
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

@@ -0,0 +1,3 @@
package checkout
// This file is now empty as mutations have been moved to separate files.

View File

@@ -0,0 +1,38 @@
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_remove_delivery.go
//
// Registers the RemoveDelivery mutation.
//
// Behavior:
// - Removes the delivery entry whose Id == payload.Id.
// - If not found, returns an error.
// - Items previously associated with that delivery simply become "without delivery";
// subsequent delivery mutations can reassign them.
func HandleRemoveDelivery(g *CheckoutGrain, 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:]...)
return nil
}

View File

@@ -0,0 +1,51 @@
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// 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 HandleSetPickupPoint(g *CheckoutGrain, 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 = &PickupPoint{
Id: m.PickupPoint.Id,
Name: m.PickupPoint.Name,
Address: m.PickupPoint.Address,
City: m.PickupPoint.City,
Zip: m.PickupPoint.Zip,
Country: m.PickupPoint.Country,
}
return nil
}
}
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
}