refactor/checkout #8

Merged
mats merged 5 commits from refactor/checkout into main 2025-12-03 09:45:57 +01:00
77 changed files with 5190 additions and 5795 deletions

View File

@@ -1,6 +1,9 @@
name: Build and Publish
run-name: ${{ gitea.actor }} build 🚀
on: [push]
on:
push:
branches:
- main
jobs:
BuildAndDeployAmd64:

View File

@@ -19,7 +19,10 @@
MODULE_PATH := git.k6n.net/go-cart-actor
PROTO_DIR := proto
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/control_plane.proto
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto
CART_PROTO_DIR := $(PROTO_DIR)/cart
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
# Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc
@@ -69,21 +72,30 @@ check_tools:
protogen: check_tools
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./pkg/messages --go_opt=paths=source_relative \
--go-grpc_out=./pkg/messages --go-grpc_opt=paths=source_relative \
$(PROTOS)
--go_out=./proto/cart --go_opt=paths=source_relative \
--go-grpc_out=./proto/cart --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/cart.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/control --go_opt=paths=source_relative \
--go-grpc_out=./proto/control --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/control_plane.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/checkout --go_opt=paths=source_relative \
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/checkout.proto
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto:
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
@rm -f $(PROTO_DIR)/*_grpc.pb.go $(PROTO_DIR)/*.pb.go
@rm -f *.pb.go
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
@echo "$(GREEN)Clean complete.$(RESET)"
verify_proto:
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
@if ls *.pb.go >/dev/null 2>&1; then \
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/).$(RESET)"; \
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/ subdirs).$(RESET)"; \
ls -1 *.pb.go; \
exit 1; \
fi

View File

@@ -1,210 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/messages"
"github.com/matst80/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
)
var tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>s10r testing - checkout</title>
</head>
<body>
%s
</body>
</html>
`
func (a *App) getGrainFromOrder(ctx context.Context, order *CheckoutOrder) (*cart.CartGrain, error) {
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
grain, err := a.pool.Get(ctx, uint64(cartId))
if err != nil {
return nil, fmt.Errorf("failed to get cart grain: %w", err)
}
return grain, nil
}
func (a *App) HandleCheckoutRequests(amqpUrl string, mux *http.ServeMux, inventoryService inventory.InventoryService) {
conn, err := amqp.Dial(amqpUrl)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
amqpListener := actor.NewAmqpListener(conn, func(id uint64, msg []actor.ApplyResult) (any, error) {
return &CartChangeEvent{
CartId: cart.CartId(id),
Mutations: msg,
}, nil
})
amqpListener.DefineTopics()
a.pool.AddListener(amqpListener)
orderHandler := NewAmqpOrderHandler(conn)
orderHandler.DefineQueue()
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order confirmation push, method: %s", r.Method)
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
grain, err := a.getGrainFromOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.Items)
err = inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
w.WriteHeader(http.StatusNotAcceptable)
return
}
a.pool.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: "success",
})
}
err = confirmOrder(r.Context(), order, orderHandler)
if err != nil {
log.Printf("Error confirming order: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = triggerOrderCompleted(r.Context(), a.server, order)
if err != nil {
log.Printf("Error processing cart message: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = a.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /checkout", a.server.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
return err
}))
mux.HandleFunc("GET /confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
// Apply ConfirmationViewed mutation
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if ok {
a.pool.Apply(r.Context(), uint64(cartId), &messages.ConfirmationViewed{})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if order.Status == "checkout_complete" {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
})
mux.HandleFunc("/notification", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order notification, method: %s", r.Method)
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
log.Printf("Klarna order notification: %s", order.ID)
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("POST /validate", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
grain, err := a.getGrainFromOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.Items)
_, err = inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "placeorder inventory check failed")
w.WriteHeader(http.StatusNotAcceptable)
return
}
}
w.WriteHeader(http.StatusOK)
})
}

View File

@@ -6,6 +6,7 @@ import (
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
@@ -24,7 +25,11 @@ func GetDiscovery() discovery.Discovery {
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
return discovery.NewK8sDiscovery(client)
timeout := int64(30)
return discovery.NewK8sDiscovery(client, v1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
func UseDiscovery(pool actor.GrainPool[*cart.CartGrain]) {

View File

@@ -2,7 +2,6 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
@@ -15,12 +14,9 @@ import (
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"git.k6n.net/go-cart-actor/pkg/promotions"
"git.k6n.net/go-cart-actor/pkg/proxy"
"git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@@ -41,9 +37,8 @@ func init() {
}
type App struct {
pool *actor.SimpleGrainPool[cart.CartGrain]
server *PoolServer
klarnaClient *KlarnaClient
pool *actor.SimpleGrainPool[cart.CartGrain]
server *PoolServer
}
var podIp = os.Getenv("POD_IP")
@@ -195,30 +190,16 @@ func main() {
log.Fatalf("Error creating cart pool: %v\n", err)
}
adyenClient := adyen.NewClient(&common.Config{
ApiKey: os.Getenv("ADYEN_API_KEY"),
Environment: common.TestEnv,
})
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, inventoryService, inventoryReservationService, adyenClient)
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), inventoryService, inventoryReservationService)
app := &App{
pool: pool,
server: syncedServer,
klarnaClient: klarnaClient,
pool: pool,
server: syncedServer,
}
mux := http.NewServeMux()
debugMux := http.NewServeMux()
if amqpUrl == "" {
log.Printf("no connection to amqp defined")
} else {
app.HandleCheckoutRequests(amqpUrl, mux, inventoryService)
}
grpcSrv, err := actor.NewControlServer[*cart.CartGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
@@ -331,31 +312,3 @@ func main() {
}
}
func triggerOrderCompleted(ctx context.Context, syncedServer *PoolServer, order *CheckoutOrder) error {
mutation := &messages.OrderCreated{
OrderId: order.ID,
Status: order.Status,
}
cid, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
_, applyErr := syncedServer.Apply(ctx, uint64(cid), mutation)
return applyErr
}
func confirmOrder(ctx context.Context, order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
orderToSend, err := json.Marshal(order)
if err != nil {
return err
}
err = orderHandler.OrderCompleted(orderToSend)
if err != nil {
return err
}
return nil
}

View File

@@ -1,29 +1,20 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"sync"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"git.k6n.net/go-cart-actor/pkg/proxy"
messages "git.k6n.net/go-cart-actor/proto/cart"
"git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
"github.com/adyen/adyen-go-api-library/v21/src/webhook"
"github.com/google/uuid"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@@ -52,20 +43,16 @@ var (
type PoolServer struct {
actor.GrainPool[*cart.CartGrain]
pod_name string
klarnaClient *KlarnaClient
adyenClient *adyen.APIClient
inventoryService inventory.InventoryService
reservationService inventory.CartReservationService
}
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient, inventoryService inventory.InventoryService, inventoryReservationService inventory.CartReservationService, adyenClient *adyen.APIClient) *PoolServer {
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, inventoryService inventory.InventoryService, inventoryReservationService inventory.CartReservationService) *PoolServer {
srv := &PoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
inventoryService: inventoryService,
reservationService: inventoryReservationService,
adyenClient: adyenClient,
}
return srv
@@ -130,71 +117,6 @@ func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, i
return s.WriteResult(w, data)
}
type SetDeliveryRequest struct {
Provider string `json:"provider"`
Items []uint32 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
delivery := SetDeliveryRequest{}
err := json.NewDecoder(r.Body).Decode(&delivery)
if err != nil {
return err
}
data, err := s.ApplyLocal(r.Context(), id, &messages.SetDelivery{
Provider: delivery.Provider,
Items: delivery.Items,
PickupPoint: delivery.PickupPoint,
})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
if err != nil {
return err
}
pickupPoint := messages.PickupPoint{}
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), id, &messages.SetPickupPoint{
DeliveryId: uint32(deliveryId),
Id: pickupPoint.Id,
Name: pickupPoint.Name,
Address: pickupPoint.Address,
City: pickupPoint.City,
Zip: pickupPoint.Zip,
Country: pickupPoint.Country,
})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
changeQuantity := messages.ChangeQuantity{}
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
@@ -282,9 +204,9 @@ type AddRequest struct {
}
func (s *PoolServer) GetReservationTime(item *messages.AddItem) time.Duration {
// TODO: Implement reservation time calculation, nil don't require reservation
return time.Minute * 15
//return nil
}
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
@@ -325,99 +247,6 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
// return json.NewEncoder(w).Encode(order)
// }
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
func (s *PoolServer) CreateOrUpdateCheckout(r *http.Request, id cart.CartId) (*CheckoutOrder, error) {
meta := GetCheckoutMetaFromRequest(r)
// Get current grain state (may be local or remote)
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return nil, err
}
if s.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.Items)
failingRequest, err := s.inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "inventory check failed", string(failingRequest.SKU), string(failingRequest.LocationID))
return nil, err
}
}
// Build pure checkout payload
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
if err != nil {
return nil, err
}
if grain.OrderReference != "" {
return s.klarnaClient.UpdateOrder(r.Context(), grain.OrderReference, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
}
}
func (s *PoolServer) ApplyCheckoutStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id cart.CartId) (*actor.MutationResult[*cart.CartGrain], error) {
// Persist initialization state via mutation (best-effort)
return s.ApplyLocal(ctx, id, &messages.InitializeCheckout{
OrderId: klarnaOrder.ID,
Status: klarnaOrder.Status,
PaymentInProgress: true,
})
}
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
// if err != nil {
@@ -431,51 +260,6 @@ func (s *PoolServer) ApplyCheckoutStarted(ctx context.Context, klarnaOrder *Chec
// }
//
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
}
}
err = fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
@@ -493,34 +277,6 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
return nil
}
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := cart.MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := cart.ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
} else {
id = parsedId
}
}
err := fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
@@ -548,12 +304,9 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
var (
tracer = otel.Tracer(name)
hmacKey = os.Getenv("ADYEN_HMAC")
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
proxyCalls metric.Int64Counter
// rollCnt metric.Int64Counter
)
func init() {
@@ -577,10 +330,6 @@ func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, c
v := voucher.Service{}
msg, err := v.GetVoucher(data.VoucherCode)
if err != nil {
s.ApplyLocal(r.Context(), cartId, &messages.PreConditionFailed{
Operation: "AddVoucher",
Error: err.Error(),
})
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
@@ -629,26 +378,6 @@ func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.R
return nil
}
func (s *PoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) {
return CookieCartIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
orderId := r.URL.Query().Get("order_id")
if orderId == "" {
order, err := s.CreateOrUpdateCheckout(r, cartId)
if err != nil {
logger.Error("unable to create klarna session", "error", err)
return err
}
s.ApplyCheckoutStarted(r.Context(), order, cartId)
return fn(order, w)
}
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
return err
}
return fn(order, w)
}))
}
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
idStr := r.PathValue("voucherId")
@@ -713,61 +442,27 @@ func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http
return s.WriteResult(w, reply)
}
func (s *PoolServer) CreateCheckoutOrderHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
createCheckoutOrder := messages.CreateCheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(&createCheckoutOrder)
func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return nil
}
data, err := io.ReadAll(r.Body)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &createCheckoutOrder)
mutation := &messages.Mutation{}
err = proto.Unmarshal(data, mutation)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, mutation)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
type SessionRequest struct {
SessionId string `json:"sessionId"`
SessionResult string `json:"sessionResult"`
SessionData string `json:"sessionData,omitempty"`
}
func (s *PoolServer) AdyenSessionHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
grain, err := s.Get(r.Context(), uint64(cartId))
if err != nil {
return err
}
if r.Method == http.MethodGet {
meta := GetCheckoutMetaFromRequest(r)
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
if err != nil {
return err
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.SessionsInput().CreateCheckoutSessionRequest(*sessionData)
res, _, err := service.PaymentsApi.Sessions(r.Context(), req)
// apply checkout started
if err != nil {
return err
}
return s.WriteResult(w, res)
} else {
payload := &SessionRequest{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return err
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(payload.SessionId).SessionResult(payload.SessionResult)
res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
if err != nil {
return err
}
return s.WriteResult(w, res)
}
}
func (s *PoolServer) GetAnywhere(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
id := uint64(cartId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
@@ -789,164 +484,6 @@ func (s *PoolServer) ApplyAnywhere(ctx context.Context, cartId cart.CartId, msgs
return err
}
func (s *PoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) {
var notificationRequest webhook.Webhook
service := s.adyenClient.Checkout()
if err := json.NewDecoder(r.Body).Decode(&notificationRequest); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cartHostMap := make(map[actor.Host][]webhook.NotificationItem)
for _, notificationItem := range *notificationRequest.NotificationItems {
item := notificationItem.NotificationRequestItem
log.Printf("Recieved notification event code: %s, %+v", item.EventCode, item)
isValid := hmacvalidator.ValidateHmac(item, hmacKey)
if !isValid {
log.Printf("notification hmac not valid %s, %v", item.EventCode, item)
http.Error(w, "Invalid HMAC", http.StatusUnauthorized)
return
} else {
switch item.EventCode {
case "CAPTURE":
log.Printf("Capture status: %v", item.Success)
// dataBytes, err := json.Marshal(item)
// if err != nil {
// log.Printf("error marshaling item: %v", err)
// http.Error(w, "Error marshaling item", http.StatusInternalServerError)
// return
// }
//s.ApplyAnywhere(r.Context(),0, &messages.PaymentEvent{PaymentId: item.PspReference, Success: item.Success, Name: item.EventCode, Data: &pbany.Any{Value: dataBytes}})
case "AUTHORISATION":
cartId, ok := cart.ParseCartId(item.MerchantReference)
if !ok {
log.Printf("invalid cart id %s", item.MerchantReference)
http.Error(w, "Invalid cart id", http.StatusBadRequest)
return
}
//s.Apply()
if host, ok := s.OwnerHost(uint64(cartId)); ok {
cartHostMap[host] = append(cartHostMap[host], notificationItem)
continue
}
grain, err := s.Get(r.Context(), uint64(cartId))
if err != nil {
log.Printf("Error getting cart: %v", err)
http.Error(w, "Cart not found", http.StatusBadRequest)
return
}
meta := GetCheckoutMetaFromRequest(r)
pspReference := item.PspReference
uid := uuid.New().String()
ref := uuid.New().String()
req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(checkout.PaymentCaptureRequest{
Amount: checkout.Amount{
Currency: meta.Currency,
Value: grain.TotalPrice.IncVat,
},
MerchantAccount: "ElgigantenECOM",
Reference: &ref,
})
res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req)
if err != nil {
log.Printf("Error capturing payment: %v", err)
} else {
log.Printf("Payment captured successfully: %+v", res)
s.Apply(r.Context(), uint64(cartId), &messages.OrderCreated{
OrderId: res.PaymentPspReference,
Status: item.EventCode,
})
}
default:
log.Printf("Unknown event code: %s", item.EventCode)
}
}
}
var failed bool = false
var lastMock *proxy.MockResponseWriter
for host, items := range cartHostMap {
notificationRequest.NotificationItems = &items
bodyBytes, err := json.Marshal(notificationRequest)
if err != nil {
log.Printf("error marshaling notification: %+v", err)
continue
}
customBody := bytes.NewReader(bodyBytes)
mockW := proxy.NewMockResponseWriter()
handled, err := host.Proxy(0, mockW, r, customBody)
if err != nil {
log.Printf("proxy failed for %s: %+v", host.Name(), err)
failed = true
lastMock = mockW
} else if handled {
log.Printf("notification proxied to %s", host.Name())
}
}
if failed {
w.WriteHeader(lastMock.StatusCode)
w.Write(lastMock.Body.Bytes())
} else {
w.WriteHeader(http.StatusAccepted)
}
}
func (s *PoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
log.Println("Redirect received")
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(r.URL.Query().Get("sessionId"))
res, httpRes, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
log.Printf("got payment session %+v", res)
dreq := service.PaymentsApi.PaymentsDetailsInput()
dreq = dreq.PaymentDetailsRequest(checkout.PaymentDetailsRequest{
Details: checkout.PaymentCompletionDetails{
RedirectResult: common.PtrString(r.URL.Query().Get("redirectResult")),
Payload: common.PtrString(r.URL.Query().Get("payload")),
},
})
dres, httpRes, err := service.PaymentsApi.PaymentsDetails(r.Context(), dreq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Payment details response: %+v", dres)
if !common.IsNil(dres.PspReference) && *dres.PspReference != "" {
var redirectURL string
// Conditionally handle different result codes for the shopper
switch *dres.ResultCode {
case "Authorised":
redirectURL = "/result/success"
case "Pending", "Received":
redirectURL = "/result/pending"
case "Refused":
redirectURL = "/result/failed"
default:
reason := ""
if dres.RefusalReason != nil {
reason = *dres.RefusalReason
} else {
reason = *dres.ResultCode
}
log.Printf("Payment failed: %s", reason)
redirectURL = fmt.Sprintf("/result/error?reason=%s", url.QueryEscape(reason))
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpRes.StatusCode)
json.NewEncoder(w).Encode(httpRes.Status)
}
func (s *PoolServer) Serve(mux *http.ServeMux) {
// mux.HandleFunc("OPTIONS /cart", func(w http.ResponseWriter, r *http.Request) {
@@ -970,9 +507,6 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
}))
}
handleFunc("/adyen_hook", s.AdyenHookHandler)
handleFunc("/adyen-return", s.AdyenReturnHandler)
handleFunc("GET /cart", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
handleFunc("GET /cart/add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
handleFunc("POST /cart/add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
@@ -981,38 +515,25 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
handleFunc("DELETE /cart/{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
handleFunc("PUT /cart", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
handleFunc("DELETE /cart", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
handleFunc("POST /cart/delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
handleFunc("DELETE /cart/delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
handleFunc("PUT /cart/delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
handleFunc("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("GET /cart/adyen-session", CookieCartIdHandler(s.ProxyHandler(s.AdyenSessionHandler)))
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
handleFunc("POST /cart/checkout-order", CookieCartIdHandler(s.ProxyHandler(s.CreateCheckoutOrderHandler)))
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
handleFunc("GET /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
handleFunc("GET /cart/byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
handleFunc("POST /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
handleFunc("DELETE /cart/byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
handleFunc("PUT /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
handleFunc("POST /cart/byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
handleFunc("DELETE /cart/byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
handleFunc("PUT /cart/byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
handleFunc("POST /cart/byid/{id}/checkout-order", CartIdHandler(s.ProxyHandler(s.CreateCheckoutOrderHandler)))
//mux.HandleFunc("GET /cart/byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /cart/byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
}

View File

@@ -7,7 +7,8 @@ import (
"net/http"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
"github.com/matst80/slask-finder/pkg/index"
)

138
cmd/cart/utils.go Normal file
View File

@@ -0,0 +1,138 @@
package main
import (
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
}
}
err = fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := cart.MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := cart.ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
} else {
id = parsedId
}
}
err := fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}

View File

@@ -0,0 +1,228 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/proxy"
messages "git.k6n.net/go-cart-actor/proto/checkout"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
"github.com/adyen/adyen-go-api-library/v21/src/webhook"
"github.com/google/uuid"
)
type SessionRequest struct {
SessionId string `json:"sessionId"`
SessionResult string `json:"sessionResult"`
SessionData string `json:"sessionData,omitempty"`
}
func (s *CheckoutPoolServer) AdyenSessionHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
grain, err := s.Get(r.Context(), uint64(cartId))
if err != nil {
return err
}
if r.Method == http.MethodGet {
meta := GetCheckoutMetaFromRequest(r)
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
if err != nil {
return err
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.SessionsInput().CreateCheckoutSessionRequest(*sessionData)
res, _, err := service.PaymentsApi.Sessions(r.Context(), req)
// apply checkout started
if err != nil {
return err
}
return s.WriteResult(w, res)
} else {
payload := &SessionRequest{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return err
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(payload.SessionId).SessionResult(payload.SessionResult)
res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
if err != nil {
return err
}
return s.WriteResult(w, res)
}
}
func getCheckoutIdFromNotificationItem(item webhook.NotificationRequestItem) (uint64, error) {
cartId, ok := cart.ParseCartId(item.MerchantReference)
if !ok {
return 0, errors.New("invalid cart id")
}
return uint64(cartId), nil
}
func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) {
var notificationRequest webhook.Webhook
service := s.adyenClient.Checkout()
if err := json.NewDecoder(r.Body).Decode(&notificationRequest); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cartHostMap := make(map[actor.Host][]webhook.NotificationItem)
for _, notificationItem := range *notificationRequest.NotificationItems {
item := notificationItem.NotificationRequestItem
log.Printf("Recieved notification event code: %s, %+v", item.EventCode, item)
isValid := hmacvalidator.ValidateHmac(item, hmacKey)
if !isValid {
log.Printf("notification hmac not valid %s, %v", item.EventCode, item)
http.Error(w, "Invalid HMAC", http.StatusUnauthorized)
return
} else {
switch item.EventCode {
case "CAPTURE":
log.Printf("Capture status: %v", item.Success)
// dataBytes, err := json.Marshal(item)
// if err != nil {
// log.Printf("error marshaling item: %v", err)
// http.Error(w, "Error marshaling item", http.StatusInternalServerError)
// return
// }
//s.ApplyAnywhere(r.Context(),0, &messages.PaymentEvent{PaymentId: item.PspReference, Success: item.Success, Name: item.EventCode, Data: &pbany.Any{Value: dataBytes}})
case "AUTHORISATION":
cartId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
//s.Apply()
if host, ok := s.OwnerHost(uint64(cartId)); ok {
cartHostMap[host] = append(cartHostMap[host], notificationItem)
continue
}
grain, err := s.Get(r.Context(), uint64(cartId))
if err != nil {
log.Printf("Error getting cart: %v", err)
http.Error(w, "Cart not found", http.StatusBadRequest)
return
}
meta := GetCheckoutMetaFromRequest(r)
pspReference := item.PspReference
uid := uuid.New().String()
ref := uuid.New().String()
req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(adyenCheckout.PaymentCaptureRequest{
Amount: adyenCheckout.Amount{
Currency: meta.Currency,
Value: grain.CartTotalPrice.IncVat,
},
MerchantAccount: "ElgigantenECOM",
Reference: &ref,
})
res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req)
if err != nil {
log.Printf("Error capturing payment: %v", err)
} else {
log.Printf("Payment captured successfully: %+v", res)
s.Apply(r.Context(), uint64(cartId), &messages.OrderCreated{
OrderId: res.PaymentPspReference,
Status: item.EventCode,
})
}
default:
log.Printf("Unknown event code: %s", item.EventCode)
}
}
}
var failed bool = false
var lastMock *proxy.MockResponseWriter
for host, items := range cartHostMap {
notificationRequest.NotificationItems = &items
bodyBytes, err := json.Marshal(notificationRequest)
if err != nil {
log.Printf("error marshaling notification: %+v", err)
continue
}
customBody := bytes.NewReader(bodyBytes)
mockW := proxy.NewMockResponseWriter()
handled, err := host.Proxy(0, mockW, r, customBody)
if err != nil {
log.Printf("proxy failed for %s: %+v", host.Name(), err)
failed = true
lastMock = mockW
} else if handled {
log.Printf("notification proxied to %s", host.Name())
}
}
if failed {
w.WriteHeader(lastMock.StatusCode)
w.Write(lastMock.Body.Bytes())
} else {
w.WriteHeader(http.StatusAccepted)
}
}
func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
log.Println("Redirect received")
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(r.URL.Query().Get("sessionId"))
res, httpRes, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
log.Printf("got payment session %+v", res)
dreq := service.PaymentsApi.PaymentsDetailsInput()
dreq = dreq.PaymentDetailsRequest(adyenCheckout.PaymentDetailsRequest{
Details: adyenCheckout.PaymentCompletionDetails{
RedirectResult: common.PtrString(r.URL.Query().Get("redirectResult")),
Payload: common.PtrString(r.URL.Query().Get("payload")),
},
})
dres, httpRes, err := service.PaymentsApi.PaymentsDetails(r.Context(), dreq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Payment details response: %+v", dres)
if !common.IsNil(dres.PspReference) && *dres.PspReference != "" {
var redirectURL string
// Conditionally handle different result codes for the shopper
switch *dres.ResultCode {
case "Authorised":
redirectURL = "/result/success"
case "Pending", "Received":
redirectURL = "/result/pending"
case "Refused":
redirectURL = "/result/failed"
default:
reason := ""
if dres.RefusalReason != nil {
reason = *dres.RefusalReason
} else {
reason = *dres.ResultCode
}
log.Printf("Payment failed: %s", reason)
redirectURL = fmt.Sprintf("/result/error?reason=%s", url.QueryEscape(reason))
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpRes.StatusCode)
json.NewEncoder(w).Encode(httpRes.Status)
}

View File

@@ -0,0 +1,65 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
)
type CartClient struct {
httpClient *http.Client
baseUrl string
}
func NewCartClient(baseUrl string) *CartClient {
return &CartClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseUrl: baseUrl,
}
}
// func (c *CartClient) ApplyMutation(cartId cart.CartId, mutation proto.Message) error {
// url := fmt.Sprintf("%s/internal/cart/%s/mutation", c.baseUrl, cartId.String())
// data, err := proto.Marshal(mutation)
// if err != nil {
// return err
// }
// req, err := http.NewRequest("POST", url, bytes.NewReader(data))
// if err != nil {
// return err
// }
// req.Header.Set("Content-Type", "application/protobuf")
// resp, err := c.httpClient.Do(req)
// if err != nil {
// return err
// }
// defer resp.Body.Close()
// if resp.StatusCode != http.StatusOK {
// return fmt.Errorf("cart mutation failed: %s", resp.Status)
// }
// return nil
// }
func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
// Call cart service to get grain
url := fmt.Sprintf("%s/cart/byid/%d", s.baseUrl, cartId.String())
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get cart: %s", resp.Status)
}
var grain cart.CartGrain
err = json.NewDecoder(resp.Body).Decode(&grain)
return &grain, err
}

View File

@@ -6,7 +6,8 @@ import (
"net/http"
"git.k6n.net/go-cart-actor/pkg/cart"
"github.com/adyen/adyen-go-api-library/v21/src/checkout"
"git.k6n.net/go-cart-actor/pkg/checkout"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
)
@@ -38,7 +39,7 @@ type CheckoutMeta struct {
//
// If you later need to support different tax rates per line, you can extend
// CartItem / Delivery to expose that data and propagate it here.
func BuildCheckoutOrderPayload(grain *cart.CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
@@ -59,10 +60,10 @@ func BuildCheckoutOrderPayload(grain *cart.CartGrain, meta *CheckoutMeta) ([]byt
country = "SE" // sensible default; adjust if multi-country support changes
}
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
lines := make([]*Line, 0, len(grain.CartState.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.Items {
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
@@ -80,11 +81,15 @@ func BuildCheckoutOrderPayload(grain *cart.CartGrain, meta *CheckoutMeta) ([]byt
})
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
//total.Add(d.Price)
lines = append(lines, &Line{
Type: "shipping_fee",
Reference: d.Provider,
@@ -102,8 +107,8 @@ func BuildCheckoutOrderPayload(grain *cart.CartGrain, meta *CheckoutMeta) ([]byt
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(grain.TotalPrice.IncVat),
OrderTaxAmount: int(grain.TotalPrice.TotalVat()),
OrderAmount: int(total.IncVat),
OrderTaxAmount: int(total.TotalVat()),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
@@ -136,7 +141,7 @@ func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
}
}
func BuildAdyenCheckoutSession(grain *cart.CartGrain, meta *CheckoutMeta) (*checkout.CreateCheckoutSessionRequest, error) {
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
if grain == nil {
return nil, fmt.Errorf("nil grain")
}
@@ -153,14 +158,14 @@ func BuildAdyenCheckoutSession(grain *cart.CartGrain, meta *CheckoutMeta) (*chec
country = "SE"
}
lineItems := make([]checkout.LineItem, 0, len(grain.Items)+len(grain.Deliveries))
lineItems := make([]adyenCheckout.LineItem, 0, len(grain.CartState.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.Items {
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lineItems = append(lineItems, checkout.LineItem{
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(int64(it.Quantity)),
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat),
Description: common.PtrString(it.Meta.Name),
@@ -169,13 +174,15 @@ func BuildAdyenCheckoutSession(grain *cart.CartGrain, meta *CheckoutMeta) (*chec
TaxPercentage: common.PtrInt64(int64(it.Tax)),
})
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lineItems = append(lineItems, checkout.LineItem{
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(1),
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
Description: common.PtrString("Delivery"),
@@ -184,10 +191,10 @@ func BuildAdyenCheckoutSession(grain *cart.CartGrain, meta *CheckoutMeta) (*chec
})
}
return &checkout.CreateCheckoutSessionRequest{
return &adyenCheckout.CreateCheckoutSessionRequest{
Reference: grain.Id.String(),
Amount: checkout.Amount{
Value: grain.TotalPrice.IncVat,
Amount: adyenCheckout.Amount{
Value: total.IncVat,
Currency: currency,
},
CountryCode: common.PtrString(country),

View File

@@ -0,0 +1,65 @@
package main
import (
"log"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/checkout"
"git.k6n.net/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
timeout := int64(30)
return discovery.NewK8sDiscovery(client, v1.ListOptions{
LabelSelector: "actor-pool=checkout",
TimeoutSeconds: &timeout,
})
}
func UseDiscovery(pool actor.GrainPool[*checkout.CheckoutGrain]) {
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.IsReady {
case false:
if pool.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemoteHost(evt.Host)
}
}
}
}(GetDiscovery())
}

View File

@@ -0,0 +1,242 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
messages "git.k6n.net/go-cart-actor/proto/checkout"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
/*
*
*
* s.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
return err
})
*/
func (s *CheckoutPoolServer) KlarnaHtmlCheckoutHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
orderId := r.URL.Query().Get("order_id")
var order *CheckoutOrder
var err error
if orderId == "" {
order, err = s.CreateOrUpdateCheckout(r, checkoutId)
if err != nil {
logger.Error("unable to create klarna session", "error", err)
return err
}
// s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
}
order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
_, err = fmt.Fprintf(w, tpl, order.HTMLSnippet)
return err
}
func (s *CheckoutPoolServer) KlarnaConfirmationHandler(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
// Apply ConfirmationViewed mutation
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if ok {
s.Apply(r.Context(), uint64(cartId), &messages.ConfirmationViewed{})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if order.Status == "checkout_complete" {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
}
func (s *CheckoutPoolServer) KlarnaValidationHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
s.reserveInventory(r.Context(), grain)
w.WriteHeader(http.StatusOK)
}
func (s *CheckoutPoolServer) KlarnaNotificationHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order notification, method: %s", r.Method)
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
log.Printf("Klarna order notification: %s", order.ID)
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
w.WriteHeader(http.StatusOK)
}
func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order confirmation push, method: %s", r.Method)
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if s.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items)
err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
if err != nil {
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
w.WriteHeader(http.StatusNotAcceptable)
return
}
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: "success",
})
}
// err = confirmOrder(r.Context(), order, orderHandler)
// if err != nil {
// log.Printf("Error confirming order: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
// }
// err = triggerOrderCompleted(r.Context(), a.server, order)
// if err != nil {
// log.Printf("Error processing cart message: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
// }
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
w.WriteHeader(http.StatusOK)
}
var tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>s10r testing - checkout</title>
</head>
<body>
%s
</body>
</html>
`
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
grain, err := a.Get(ctx, uint64(cartId))
if err != nil {
return nil, fmt.Errorf("failed to get cart grain: %w", err)
}
return grain, nil
}

196
cmd/checkout/main.go Normal file
View File

@@ -0,0 +1,196 @@
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/checkout"
"git.k6n.net/go-cart-actor/pkg/proxy"
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_spawned_total",
Help: "The total number of spawned checkout grains",
})
)
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *actor.SimpleGrainPool[checkout.CheckoutGrain]
server *CheckoutPoolServer
klarnaClient *KlarnaClient
cartClient *CartClient // For internal communication to cart
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
func main() {
controlPlaneConfig := actor.DefaultServerConfig()
reg := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *checkout.CheckoutGrain) error {
g.Version++
return nil
}),
)
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Error creating inventory service: %v\n", err)
}
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain]("data", reg)
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
defer span.End()
grainSpawns.Inc()
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
return ret, nil
},
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
return nil
},
SpawnHost: func(host string) (actor.Host, error) {
return proxy.NewRemoteHost[checkout.CheckoutGrain](host)
},
TTL: 1 * time.Hour, // Longer TTL for checkout
PoolSize: 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Fatalf("Error creating checkout pool: %v\n", err)
}
adyenClient := adyen.NewClient(&common.Config{
ApiKey: os.Getenv("ADYEN_API_KEY"),
Environment: common.TestEnv,
})
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
cartClient := NewCartClient(cartInternalUrl)
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient)
syncedServer.inventoryService = inventoryService
mux := http.NewServeMux()
debugMux := http.NewServeMux()
if amqpUrl == "" {
log.Fatalf("no connection to amqp defined")
}
grpcSrv, err := actor.NewControlServer[*checkout.CheckoutGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
UseDiscovery(pool)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := setupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
syncedServer.Serve(mux)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
grainCount, capacity := pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("control plane not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
})
srv := &http.Server{
Addr: ":8080",
BaseContext: func(net.Listener) context.Context { return ctx },
ReadTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
Handler: otelhttp.NewHandler(mux, "/"),
}
defer func() {
fmt.Println("Shutting down due to signal")
otelShutdown(context.Background())
diskStorage.Close()
pool.Close()
}()
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
log.Print("Checkout server started at port 8080")
go http.ListenAndServe(":8081", debugMux)
select {
case err = <-srvErr:
log.Fatalf("Unable to start server: %v", err)
case <-ctx.Done():
stop()
}
}

117
cmd/checkout/otel.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"context"
"errors"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/trace"
)
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
// If it does not return an error, make sure to call shutdown for proper cleanup.
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
var shutdownFuncs []func(context.Context) error
var err error
// shutdown calls cleanup functions registered via shutdownFuncs.
// The errors from the calls are joined.
// Each registered cleanup will be invoked once.
shutdown := func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
// Set up propagator.
prop := newPropagator()
otel.SetTextMapPropagator(prop)
// Set up trace provider.
tracerProvider, err := newTracerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
// Set up meter provider.
meterProvider, err := newMeterProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
// Set up logger provider.
loggerProvider, err := newLoggerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider)
return shutdown, err
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
func newTracerProvider() (*trace.TracerProvider, error) {
traceExporter, err := otlptracegrpc.New(context.Background())
if err != nil {
return nil, err
}
tracerProvider := trace.NewTracerProvider(
trace.WithBatcher(traceExporter,
// Default is 5s. Set to 1s for demonstrative purposes.
trace.WithBatchTimeout(time.Second)),
)
return tracerProvider, nil
}
func newMeterProvider() (*metric.MeterProvider, error) {
exporter, err := otlpmetricgrpc.New(context.Background())
if err != nil {
return nil, err
}
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
return provider, nil
}
func newLoggerProvider() (*log.LoggerProvider, error) {
logExporter, err := otlploggrpc.New(context.Background())
if err != nil {
return nil, err
}
loggerProvider := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExporter)),
)
return loggerProvider, nil
}

237
cmd/checkout/pool-server.go Normal file
View File

@@ -0,0 +1,237 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
messages "git.k6n.net/go-cart-actor/proto/checkout"
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/matst80/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
var (
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_mutations_total",
Help: "The total number of mutations",
})
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_lookups_total",
Help: "The total number of lookups",
})
)
type CheckoutPoolServer struct {
actor.GrainPool[*checkout.CheckoutGrain]
pod_name string
klarnaClient *KlarnaClient
adyenClient *adyen.APIClient
cartClient *CartClient
inventoryService *inventory.RedisInventoryService
}
func NewCheckoutPoolServer(pool actor.GrainPool[*checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer {
srv := &CheckoutPoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
cartClient: cartClient,
adyenClient: adyenClient,
}
return srv
}
func (s *CheckoutPoolServer) ApplyLocal(ctx context.Context, id checkout.CheckoutId, mutation ...proto.Message) (*actor.MutationResult[*checkout.CheckoutGrain], error) {
return s.Apply(ctx, uint64(id), mutation...)
}
func (s *CheckoutPoolServer) GetCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
func (s *CheckoutPoolServer) WriteResult(w http.ResponseWriter, result any) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name)
if result == nil {
w.WriteHeader(http.StatusInternalServerError)
return nil
}
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
err := enc.Encode(result)
return err
}
func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, id checkout.CheckoutId) (*CheckoutOrder, error) {
// Get cart state from cart service
cartGrain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return nil, err
}
meta := GetCheckoutMetaFromRequest(r)
payload, _, err := BuildCheckoutOrderPayload(cartGrain, meta)
if err != nil {
return nil, err
}
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return nil, err
}
if grain.OrderId != nil {
return s.klarnaClient.UpdateOrder(r.Context(), *grain.OrderId, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
}
}
func (s *CheckoutPoolServer) ApplyKlarnaPaymentStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id checkout.CheckoutId) (*actor.MutationResult[*checkout.CheckoutGrain], error) {
method := "checkout"
return s.ApplyLocal(ctx, id, &messages.PaymentStarted{
PaymentId: klarnaOrder.ID,
Amount: int64(klarnaOrder.OrderAmount),
Currency: klarnaOrder.PurchaseCurrency,
Provider: "klarna",
Method: &method,
StartedAt: timestamppb.New(time.Now()),
})
}
// func (s *CheckoutPoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) {
// return CheckoutIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
// orderId := r.URL.Query().Get("order_id")
// if orderId == "" {
// order, err := s.CreateOrUpdateCheckout(r, checkoutId)
// if err != nil {
// logger.Error("unable to create klarna session", "error", err)
// return err
// }
// s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
// return fn(order, w)
// }
// order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
// if err != nil {
// return err
// }
// return fn(order, w)
// }))
// }
var (
tracer = otel.Tracer(name)
hmacKey = os.Getenv("ADYEN_HMAC")
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
proxyCalls metric.Int64Counter
)
func init() {
var err error
proxyCalls, err = meter.Int64Counter("proxy.calls",
metric.WithDescription("Number of proxy calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
}
func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
attr := attribute.String("http.route", pattern)
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
span := trace.SpanFromContext(r.Context())
span.SetName(pattern)
span.SetAttributes(attr)
labeler, _ := otelhttp.LabelerFromContext(r.Context())
labeler.Add(attr)
handlerFunc(w, r)
}))
}
handleFunc("/payment/adyen/session", CheckoutIdHandler(s.AdyenSessionHandler))
handleFunc("/payment/adyen/push", s.AdyenHookHandler)
handleFunc("/payment/adyen/return", s.AdyenReturnHandler)
//handleFunc("/payment/adyen/cancel", s.AdyenCancelHandler)
handleFunc("/payment/klarna/validate", s.KlarnaValidationHandler)
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
conn, err := amqp.Dial(amqpUrl)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
orderHandler := NewAmqpOrderHandler(conn)
orderHandler.DefineQueue()
handleFunc("GET /checkout", CheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
handleFunc("GET /confirmation/{order_id}", s.KlarnaConfirmationHandler)
// handleFunc("GET /checkout", s.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
// w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
// w.WriteHeader(http.StatusOK)
// _, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
// return err
// }))
handleFunc("GET /confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
// Apply ConfirmationViewed to checkout
checkoutId := checkout.CheckoutId(cart.MustNewCartId()) // Need to resolve from order
s.Apply(r.Context(), uint64(checkoutId), &messages.ConfirmationViewed{})
// Callback to cart
// cartId := cart.CartId(checkoutId) // Assuming same
// s.cartClient.ApplyMutation(cartId, &messages.OrderCreated{OrderId: order.ID, Status: order.Status})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
})
}

115
cmd/checkout/utils.go Normal file
View File

@@ -0,0 +1,115 @@
package main
import (
"context"
"log"
"net/http"
"strings"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
if strings.Contains(strings.ToLower(host), "-se") {
return "se"
}
return ""
}
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
if a.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items)
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
if err != nil {
logger.WarnContext(ctx, "placeorder inventory check failed")
return err
}
}
return nil
}
func CheckoutIdHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id checkout.CheckoutId
raw := r.PathValue("id")
if raw == "" {
id = checkout.CheckoutId(cart.MustNewCartId())
w.Header().Set("Set-Checkout-Id", id.String())
} else {
if parsedId, ok := cart.ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("checkout id is invalid"))
return
} else {
id = checkout.CheckoutId(parsedId)
}
}
err := fn(w, r, id)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
return func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
if ownerHost, ok := s.OwnerHost(uint64(checkoutId)); ok {
ctx, span := tracer.Start(r.Context(), "proxy")
defer span.End()
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
hostAttr := attribute.String("other host", ownerHost.Name())
span.SetAttributes(hostAttr)
logger.InfoContext(ctx, "checkout proxyed", "result", ownerHost.Name())
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
grainLookups.Inc()
if err == nil && handled {
return nil
}
}
_, span := tracer.Start(r.Context(), "own")
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
defer span.End()
return fn(w, r, checkoutId)
}
}

View File

@@ -357,6 +357,139 @@ spec:
ports:
- name: web
port: 8080
- name: internal
port: 8081
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: checkout-actor
arch: amd64
name: checkout-actor-x86
spec:
replicas: 3
selector:
matchLabels:
app: checkout-actor
arch: amd64
template:
metadata:
labels:
app: checkout-actor
actor-pool: checkout
arch: amd64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/checkout-actor
server: 10.10.1.10
serviceAccountName: default
containers:
- image: registry.k6n.net/go-checkout-actor-amd64:latest
name: checkout-actor-amd64
imagePullPolicy: Always
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 50
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
value: "10.10.3.18:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=checkout,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: password
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
- name: CART_INTERNAL_URL
value: "http://cart-actor:8081"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
kind: Service
apiVersion: v1
metadata:
name: checkout-actor
annotations:
prometheus.io/port: "8081"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
selector:
app: checkout-actor
ports:
- name: web
port: 8080
---
kind: Service
apiVersion: v1

View File

@@ -8,7 +8,7 @@ import (
"net"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
@@ -145,7 +145,7 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
}
// ControlPlane: Ping
func (s *ControlServer[V]) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
func (s *ControlServer[V]) Ping(ctx context.Context, req *messages.Empty) (*messages.PingReply, error) {
host := s.pool.Hostname()
@@ -191,7 +191,7 @@ func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.Negotiat
}
// ControlPlane: GetCartIds (locally owned carts only)
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, _ *messages.Empty) (*messages.ActorIdsReply, error) {
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.Empty) (*messages.ActorIdsReply, error) {
ctx, span := tracer.Start(ctx, "grpc_get_local_actor_ids")
defer span.End()
ids := s.pool.GetLocalIds()

View File

@@ -4,7 +4,8 @@ import (
"context"
"testing"
"git.k6n.net/go-cart-actor/pkg/messages"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
control_plane_messages "git.k6n.net/go-cart-actor/proto/control"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
@@ -70,12 +71,12 @@ func TestApplyRequestWithMutations(t *testing.T) {
}
defer conn.Close()
client := messages.NewControlPlaneClient(conn)
client := control_plane_messages.NewControlPlaneClient(conn)
// Prepare ApplyRequest with multiple Any messages
addItemAny, _ := anypb.New(&messages.AddItem{ItemId: 1, Quantity: 2})
removeItemAny, _ := anypb.New(&messages.RemoveItem{Id: 1})
req := &messages.ApplyRequest{
addItemAny, _ := anypb.New(&cart_messages.AddItem{ItemId: 1, Quantity: 2})
removeItemAny, _ := anypb.New(&cart_messages.RemoveItem{Id: 1})
req := &control_plane_messages.ApplyRequest{
Id: 123,
Messages: []*anypb.Any{addItemAny, removeItemAny},
}
@@ -95,10 +96,10 @@ func TestApplyRequestWithMutations(t *testing.T) {
if len(pool.applied) != 2 {
t.Errorf("expected 2 mutations applied, got %d", len(pool.applied))
}
if addItem, ok := pool.applied[0].(*messages.AddItem); !ok || addItem.ItemId != 1 {
if addItem, ok := pool.applied[0].(*cart_messages.AddItem); !ok || addItem.ItemId != 1 {
t.Errorf("expected AddItem with ItemId=1, got %v", pool.applied[0])
}
if removeItem, ok := pool.applied[1].(*messages.RemoveItem); !ok || removeItem.Id != 1 {
if removeItem, ok := pool.applied[1].(*cart_messages.RemoveItem); !ok || removeItem.Id != 1 {
t.Errorf("expected RemoveItem with Id=1, got %v", pool.applied[1])
}
}

View File

@@ -95,14 +95,18 @@ type MutationHandler interface {
type RegisteredMutation[V any, T proto.Message] struct {
name string
handler func(*V, T) error
create func() T
create func() proto.Message
msgType reflect.Type
}
func NewMutation[V any, T proto.Message](handler func(*V, T) error, create func() T) *RegisteredMutation[V, T] {
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredMutation[V, T] {
// Derive the name and message type from a concrete instance produced by create().
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
// and ensures we always peel off the pointer layer for proto messages.
create := func() proto.Message {
m := new(T)
return *m
}
instance := create()
rt := reflect.TypeOf(instance)
if rt.Kind() == reflect.Ptr {

View File

@@ -6,33 +6,32 @@ import (
"slices"
"testing"
"git.k6n.net/go-cart-actor/pkg/messages"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
)
type cartState struct {
calls int
lastAdded *messages.AddItem
lastAdded *cart_messages.AddItem
}
func TestRegisteredMutationBasics(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *messages.AddItem) error {
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
// copy to avoid external mutation side-effects (not strictly necessary for the test)
cp := msg
state.lastAdded = cp
return nil
},
func() *messages.AddItem { return &messages.AddItem{} },
)
// Sanity check on mutation metadata
if addItemMutation.Name() != "AddItem" {
t.Fatalf("expected mutation Name() == AddItem, got %s", addItemMutation.Name())
}
if got, want := addItemMutation.Type(), reflect.TypeOf(messages.AddItem{}); got != want {
if got, want := addItemMutation.Type(), reflect.TypeOf(cart_messages.AddItem{}); got != want {
t.Fatalf("expected Type() == %v, got %v", want, got)
}
@@ -46,18 +45,18 @@ func TestRegisteredMutationBasics(t *testing.T) {
// RegisteredMutationTypes: membership (order not guaranteed)
types := reg.RegisteredMutationTypes()
if !slices.Contains(types, reflect.TypeOf(messages.AddItem{})) {
if !slices.Contains(types, reflect.TypeOf(cart_messages.AddItem{})) {
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
}
// GetTypeName should resolve for a pointer instance
name, ok := reg.GetTypeName(&messages.AddItem{})
name, ok := reg.GetTypeName(&cart_messages.AddItem{})
if !ok || name != "AddItem" {
t.Fatalf("GetTypeName returned (%q,%v), expected (AddItem,true)", name, ok)
}
// GetTypeName should fail for unregistered type
if name, ok := reg.GetTypeName(&messages.Noop{}); ok || name != "" {
if name, ok := reg.GetTypeName(&cart_messages.RemoveItem{}); ok || name != "" {
t.Fatalf("expected GetTypeName to fail for unregistered message, got (%q,%v)", name, ok)
}
@@ -66,7 +65,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
if !ok {
t.Fatalf("Create failed for registered mutation")
}
if _, isAddItem := msg.(*messages.AddItem); !isAddItem {
if _, isAddItem := msg.(*cart_messages.AddItem); !isAddItem {
t.Fatalf("Create returned wrong concrete type: %T", msg)
}
@@ -77,7 +76,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
// Apply happy path
state := &cartState{}
add := &messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
if _, err := reg.Apply(context.Background(), state, add); err != nil {
t.Fatalf("Apply returned error: %v", err)
}
@@ -99,7 +98,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
}
// Apply unregistered message
_, err := reg.Apply(context.Background(), state, &messages.Noop{})
_, err := reg.Apply(context.Background(), state, &cart_messages.RemoveItem{})
if err != ErrMutationNotRegistered {
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
}

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,6 @@ import (
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/pkg/messages"
"github.com/matst80/go-redis-inventory/pkg/inventory"
)
@@ -57,88 +56,28 @@ func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sk
return c.reservationService.ReleaseForCart(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
}
func Create[T any]() func() *T {
return func() *T {
return new(T)
}
}
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
reg := actor.NewMutationRegistry()
reg.RegisterMutations(
actor.NewMutation(context.AddItem, func() *messages.AddItem {
return &messages.AddItem{}
}),
actor.NewMutation(context.ChangeQuantity, func() *messages.ChangeQuantity {
return &messages.ChangeQuantity{}
}),
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{}
}),
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
return &messages.AddVoucher{}
}),
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
return &messages.RemoveVoucher{}
}),
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{}
}),
actor.NewMutation(LineItemMarking, func() *messages.LineItemMarking {
return &messages.LineItemMarking{}
}),
actor.NewMutation(RemoveLineItemMarking, func() *messages.RemoveLineItemMarking {
return &messages.RemoveLineItemMarking{}
}),
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{}
}),
actor.NewMutation(context.AddItem),
actor.NewMutation(context.ChangeQuantity),
actor.NewMutation(context.RemoveItem),
actor.NewMutation(ClearCart),
actor.NewMutation(AddVoucher),
actor.NewMutation(RemoveVoucher),
actor.NewMutation(UpsertSubscriptionDetails),
actor.NewMutation(SetUserId),
actor.NewMutation(LineItemMarking),
actor.NewMutation(RemoveLineItemMarking),
actor.NewMutation(SubscriptionAdded),
// actor.NewMutation(SubscriptionRemoved),
)
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,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,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,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

@@ -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.

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,31 @@
package checkout
import (
"git.k6n.net/go-cart-actor/pkg/actor"
)
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),
actor.NewMutation(HandlePaymentStarted),
actor.NewMutation(HandlePaymentCompleted),
actor.NewMutation(HandlePaymentDeclined),
actor.NewMutation(HandlePaymentEvent),
actor.NewMutation(HandleConfirmationViewed),
actor.NewMutation(HandleOrderCreated),
actor.NewMutation(HandleInventoryReserved),
actor.NewMutation(HandleSetDelivery),
actor.NewMutation(HandleSetPickupPoint),
actor.NewMutation(HandleRemoveDelivery),
)
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

@@ -1,9 +1,9 @@
package cart
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_order_created.go
@@ -11,21 +11,20 @@ import (
// 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.
// 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 (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()).
// - 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.OrderReference != "" && g.OrderReference != m.OrderId { ... }
// if g.OrderId != "" && g.OrderId != m.OrderId { ... }
// - Add audit logging or metrics here if required.
//
// Concurrency:
@@ -33,16 +32,18 @@ import (
// 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 {
func HandleOrderCreated(g *CheckoutGrain, m *messages.OrderCreated) error {
if m == nil {
return fmt.Errorf("OrderCreated: nil payload")
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")
}
g.OrderReference = m.OrderId
//g.PaymentStatus = m.Status
//g.PaymentInProgress = false
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

@@ -1,48 +1,48 @@
package cart
package checkout
import (
"fmt"
"strings"
"time"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// PaymentStarted registers the beginning of a payment attempt for a cart.
// 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 cart as having an in-progress payment, and recalculates the PaidInFull flag.
func PaymentStarted(grain *CartGrain, msg *messages.PaymentStarted) error {
if msg == nil {
// 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(msg.PaymentId)
paymentID := strings.TrimSpace(m.PaymentId)
if paymentID == "" {
return fmt.Errorf("PaymentStarted: missing paymentId")
}
if msg.Amount < 0 {
if m.Amount < 0 {
return fmt.Errorf("PaymentStarted: amount cannot be negative")
}
currency := strings.TrimSpace(msg.Currency)
provider := strings.TrimSpace(msg.Provider)
method := copyOptionalString(msg.Method)
currency := strings.TrimSpace(m.Currency)
provider := strings.TrimSpace(m.Provider)
method := copyOptionalString(m.Method)
startedAt := time.Now().UTC()
if msg.StartedAt != nil {
startedAt = msg.StartedAt.AsTime()
if m.StartedAt != nil {
startedAt = m.StartedAt.AsTime()
}
payment, found := grain.FindPayment(paymentID)
payment, found := g.FindPayment(paymentID)
if found {
if payment.Status != PaymentStatusPending {
if payment.Status != "pending" {
return fmt.Errorf("PaymentStarted: payment already started")
}
if payment.PaymentId != paymentID {
payment.PaymentId = paymentID
}
payment.Status = PaymentStatusPending
payment.Amount = msg.Amount
payment.Status = "pending"
payment.Amount = m.Amount
if currency != "" {
payment.Currency = currency
}
@@ -56,10 +56,11 @@ func PaymentStarted(grain *CartGrain, msg *messages.PaymentStarted) error {
payment.CompletedAt = nil
payment.ProcessorReference = nil
} else {
grain.Payments = append(grain.Payments, &CartPayment{
g.PaymentInProgress++
g.Payments = append(g.Payments, &Payment{
PaymentId: paymentID,
Status: PaymentStatusPending,
Amount: msg.Amount,
Status: "pending",
Amount: m.Amount,
Currency: currency,
Provider: provider,
Method: method,
@@ -67,9 +68,6 @@ func PaymentStarted(grain *CartGrain, msg *messages.PaymentStarted) error {
})
}
grain.PaymentInProgress++
grain.PaymentStatus = PaymentStatusPending
return nil
}

View File

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

View File

@@ -1,9 +1,9 @@
package cart
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_remove_delivery.go
@@ -13,26 +13,13 @@ import (
// 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 {
func HandleRemoveDelivery(g *CheckoutGrain, 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 {
@@ -47,6 +34,5 @@ func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
// Remove delivery (order not preserved beyond necessity)
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
g.UpdateTotals()
return nil
}

View File

@@ -1,9 +1,9 @@
package cart
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_set_pickup_point.go
@@ -29,37 +29,23 @@ import (
// - Track history / audit of pickup point changes
// - Trigger delivery price adjustments (which would then require WithTotals()).
func SetPickupPoint(g *CartGrain, m *messages.SetPickupPoint) error {
func HandleSetPickupPoint(g *CheckoutGrain, 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,
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)
}
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

@@ -14,17 +14,16 @@ import (
)
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
ctx context.Context
client *kubernetes.Clientset
listOptions metav1.ListOptions
}
func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("")
}
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
})
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, k.listOptions)
if err != nil {
return nil, err
}
@@ -44,14 +43,10 @@ func hasReadyCondition(pod *v1.Pod) bool {
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
timeout := int64(30)
ipsThatAreReady := make(map[string]bool)
m := sync.Mutex{}
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
return k.client.CoreV1().Pods("").Watch(k.ctx, k.listOptions)
}
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
@@ -82,9 +77,10 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
return ch, nil
}
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOptions) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
ctx: context.Background(),
client: client,
listOptions: listOptions,
}
}

View File

@@ -4,6 +4,7 @@ import (
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
@@ -17,7 +18,9 @@ func TestDiscovery(t *testing.T) {
if err != nil {
t.Errorf("Error creating client: %v", err)
}
d := NewK8sDiscovery(client)
d := NewK8sDiscovery(client, metav1.ListOptions{
LabelSelector: "app",
})
res, err := d.DiscoverInNamespace("")
if err != nil {
t.Errorf("Error discovering: %v", err)
@@ -36,7 +39,9 @@ func TestWatch(t *testing.T) {
if err != nil {
t.Errorf("Error creating client: %v", err)
}
d := NewK8sDiscovery(client)
d := NewK8sDiscovery(client, metav1.ListOptions{
LabelSelector: "app",
})
ch, err := d.Watch()
if err != nil {
t.Errorf("Error watching: %v", err)

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ import (
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"os"
"git.k6n.net/go-cart-actor/pkg/messages"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
type Rule struct {

96
proto/cart.proto Normal file
View File

@@ -0,0 +1,96 @@
syntax = "proto3";
package cart_messages;
option go_package = "git.k6n.net/go-cart-actor/proto/cart;cart_messages";
import "google/protobuf/any.proto";
import "google/protobuf/timestamp.proto";
message ClearCartRequest {}
message AddItem {
uint32 item_id = 1;
int32 quantity = 2;
int64 price = 3;
int64 orgPrice = 9;
string sku = 4;
string name = 5;
string image = 6;
int32 stock = 7;
int32 tax = 8;
string brand = 13;
string category = 14;
string category2 = 15;
string category3 = 16;
string category4 = 17;
string category5 = 18;
string disclaimer = 10;
string articleType = 11;
string sellerId = 19;
string sellerName = 20;
string country = 21;
string saleStatus = 24;
optional string outlet = 12;
optional string storeId = 22;
optional uint32 parentId = 23;
string cgm = 25;
optional google.protobuf.Timestamp reservationEndTime = 26;
}
message RemoveItem { uint32 Id = 1; }
message ChangeQuantity {
uint32 Id = 1;
int32 quantity = 2;
}
message SetUserId {
string userId = 1;
}
message LineItemMarking {
uint32 id = 1;
uint32 type = 2;
string marking = 3;
}
message RemoveLineItemMarking {
uint32 id = 1;
}
message SubscriptionAdded {
uint32 itemId = 1;
string detailsId = 3;
string orderReference = 4;
}
message AddVoucher {
string code = 1;
int64 value = 2;
repeated string voucherRules = 3;
string description = 4;
}
message RemoveVoucher { uint32 id = 1; }
message UpsertSubscriptionDetails {
optional string id = 1;
string offeringCode = 2;
string signingType = 3;
google.protobuf.Any data = 4;
}
message Mutation {
oneof type {
ClearCartRequest clear_cart = 1;
AddItem add_item = 2;
RemoveItem remove_item = 3;
ChangeQuantity change_quantity = 4;
SetUserId set_user_id = 5;
LineItemMarking line_item_marking = 6;
RemoveLineItemMarking remove_line_item_marking = 7;
SubscriptionAdded subscription_added = 8;
AddVoucher add_voucher = 20;
RemoveVoucher remove_voucher = 21;
UpsertSubscriptionDetails upsert_subscription_details = 22;
}
}

1266
proto/cart/cart.pb.go Normal file

File diff suppressed because it is too large Load Diff

106
proto/checkout.proto Normal file
View File

@@ -0,0 +1,106 @@
syntax = "proto3";
package checkout_messages;
option go_package = "git.k6n.net/go-cart-actor/proto/checkout;checkout_messages";
import "google/protobuf/any.proto";
import "google/protobuf/timestamp.proto";
message SetDelivery {
string provider = 1;
repeated uint32 items = 2;
optional PickupPoint pickupPoint = 3;
string country = 4;
string zip = 5;
optional string address = 6;
optional string city = 7;
}
message SetPickupPoint {
uint32 deliveryId = 1;
PickupPoint pickupPoint = 2;
}
message PickupPoint {
string id = 2;
optional string name = 3;
optional string address = 4;
optional string city = 5;
optional string zip = 6;
optional string country = 7;
}
message RemoveDelivery { uint32 id = 1; }
message PaymentStarted {
string paymentId = 1;
int64 amount = 3;
string currency = 4;
string provider = 5;
optional string method = 6;
optional google.protobuf.Timestamp startedAt = 7;
repeated uint32 items = 2;
}
message PaymentCompleted {
string paymentId = 1;
string status = 2;
int64 amount = 3;
string currency = 4;
optional string processorReference = 5;
optional google.protobuf.Timestamp completedAt = 6;
}
message PaymentDeclined {
string paymentId = 1;
string message = 2;
optional string code = 3;
}
message PaymentEvent {
string paymentId = 1;
string name = 2;
bool success = 3;
google.protobuf.Any data = 4;
}
message ConfirmationViewed {
google.protobuf.Timestamp viewedAt = 1;
}
message OrderCreated {
string orderId = 1;
string status = 2;
google.protobuf.Timestamp createdAt = 3;
}
message InitializeCheckout {
string orderId = 1;
uint64 cartId = 2;
uint32 version = 3;
google.protobuf.Any cartState = 4;
}
message InventoryReserved {
string id = 1;
string status = 2;
optional string message = 3;
}
message Mutation {
oneof type {
SetDelivery set_delivery = 1;
SetPickupPoint set_pickup_point = 2;
RemoveDelivery remove_delivery = 3;
PaymentDeclined payment_declined = 4;
ConfirmationViewed confirmation_viewed = 5;
OrderCreated order_created = 7;
InitializeCheckout initialize_checkout = 9;
InventoryReserved inventory_reserved = 10;
PaymentStarted payment_started = 11;
PaymentCompleted payment_completed = 12;
PaymentEvent payment_event = 13;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
// protoc v6.33.1
// source: control_plane.proto
package messages
package control_plane_messages
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
@@ -640,84 +640,100 @@ var File_control_plane_proto protoreflect.FileDescriptor
var file_control_plane_proto_rawDesc = string([]byte{
0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a,
0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d,
0x70, 0x74, 0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79,
0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x68, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x74, 0x69, 0x6d,
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e, 0x69, 0x78, 0x54, 0x69, 0x6d,
0x65, 0x22, 0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x68,
0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x6b, 0x6e, 0x6f, 0x77,
0x6e, 0x48, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x68, 0x6f, 0x73, 0x74,
0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x21,
0x0a, 0x0d, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12,
0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64,
0x73, 0x22, 0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x41, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12,
0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23, 0x0a, 0x0d, 0x43, 0x6c, 0x6f,
0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f,
0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x22, 0x39,
0x0a, 0x11, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x41, 0x6e, 0x6e, 0x6f, 0x75,
0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02,
0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36, 0x0a, 0x0e, 0x45, 0x78, 0x70,
0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68,
0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12,
0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64,
0x73, 0x22, 0x50, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69,
0x64, 0x12, 0x30, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x22, 0x1c, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69,
0x64, 0x22, 0x36, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x2a, 0x0a,
0x05, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41,
0x6e, 0x79, 0x52, 0x05, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x22, 0x29, 0x0a, 0x0b, 0x41, 0x70, 0x70,
0x6c, 0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x32, 0xf6, 0x03, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
0x50, 0x6c, 0x61, 0x6e, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x0f, 0x2e,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x13,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65,
0x70, 0x6c, 0x79, 0x12, 0x41, 0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65,
0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f,
0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74,
0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x3c, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63,
0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x0f, 0x2e, 0x6d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52,
0x65, 0x70, 0x6c, 0x79, 0x12, 0x4a, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65,
0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x1b, 0x2e, 0x6d, 0x65, 0x73, 0x73,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x16, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70,
0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x19, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61,
0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74,
0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12,
0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f,
0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e, 0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22,
0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73,
0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48,
0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74,
0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d,
0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a,
0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22,
0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63,
0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a,
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23, 0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69,
0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11,
0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63,
0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03,
0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72,
0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73,
0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a,
0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22,
0x50, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x12,
0x30, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x73, 0x22, 0x1c, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x22,
0x36, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x67,
0x72, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f,
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79,
0x52, 0x05, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x22, 0x29, 0x0a, 0x0b, 0x41, 0x70, 0x70, 0x6c, 0x79,
0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74,
0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74,
0x65, 0x64, 0x32, 0xd6, 0x05, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x50, 0x6c,
0x61, 0x6e, 0x65, 0x12, 0x48, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x1d, 0x2e, 0x63, 0x6f,
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x21, 0x2e, 0x63, 0x6f, 0x6e,
0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x5d, 0x0a,
0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x12, 0x28, 0x2e, 0x63, 0x6f, 0x6e,
0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70,
0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65,
0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x58, 0x0a, 0x10,
0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73,
0x12, 0x1d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a,
0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64,
0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x66, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e,
0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x29, 0x2e, 0x63, 0x6f,
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x41, 0x6e,
0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b,
0x12, 0x36, 0x0a, 0x05, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x16, 0x2e, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x15, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x70, 0x70,
0x6c, 0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x44, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f,
0x75, 0x6e, 0x63, 0x65, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x6d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f,
0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x3c,
0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69,
0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77,
0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x2f, 0x0a, 0x03,
0x47, 0x65, 0x74, 0x12, 0x14, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x47,
0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x42, 0x2a, 0x5a,
0x28, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x67, 0x6f, 0x2d,
0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x3b, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x33,
0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x52,
0x0a, 0x05, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f,
0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e,
0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x73, 0x75,
0x6c, 0x74, 0x12, 0x60, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45, 0x78,
0x70, 0x69, 0x72, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70,
0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x78,
0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e, 0x63,
0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67,
0x65, 0x41, 0x63, 0x6b, 0x12, 0x58, 0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x12,
0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67,
0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x4b,
0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f,
0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x47,
0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x63, 0x6f, 0x6e, 0x74,
0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x42, 0x40, 0x5a, 0x3e, 0x67,
0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61,
0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63,
0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x3b, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70,
0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
@@ -734,40 +750,40 @@ func file_control_plane_proto_rawDescGZIP() []byte {
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_control_plane_proto_goTypes = []any{
(*Empty)(nil), // 0: messages.Empty
(*PingReply)(nil), // 1: messages.PingReply
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
(*ActorIdsReply)(nil), // 4: messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
(*ApplyRequest)(nil), // 9: messages.ApplyRequest
(*GetRequest)(nil), // 10: messages.GetRequest
(*GetReply)(nil), // 11: messages.GetReply
(*ApplyResult)(nil), // 12: messages.ApplyResult
(*Empty)(nil), // 0: control_plane_messages.Empty
(*PingReply)(nil), // 1: control_plane_messages.PingReply
(*NegotiateRequest)(nil), // 2: control_plane_messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: control_plane_messages.NegotiateReply
(*ActorIdsReply)(nil), // 4: control_plane_messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: control_plane_messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: control_plane_messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: control_plane_messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: control_plane_messages.ExpiryAnnounce
(*ApplyRequest)(nil), // 9: control_plane_messages.ApplyRequest
(*GetRequest)(nil), // 10: control_plane_messages.GetRequest
(*GetReply)(nil), // 11: control_plane_messages.GetReply
(*ApplyResult)(nil), // 12: control_plane_messages.ApplyResult
(*anypb.Any)(nil), // 13: google.protobuf.Any
}
var file_control_plane_proto_depIdxs = []int32{
13, // 0: messages.ApplyRequest.messages:type_name -> google.protobuf.Any
13, // 1: messages.GetReply.grain:type_name -> google.protobuf.Any
0, // 2: messages.ControlPlane.Ping:input_type -> messages.Empty
2, // 3: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
0, // 4: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty
7, // 5: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce
9, // 6: messages.ControlPlane.Apply:input_type -> messages.ApplyRequest
8, // 7: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce
6, // 8: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
10, // 9: messages.ControlPlane.Get:input_type -> messages.GetRequest
1, // 10: messages.ControlPlane.Ping:output_type -> messages.PingReply
3, // 11: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
4, // 12: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply
5, // 13: messages.ControlPlane.AnnounceOwnership:output_type -> messages.OwnerChangeAck
12, // 14: messages.ControlPlane.Apply:output_type -> messages.ApplyResult
5, // 15: messages.ControlPlane.AnnounceExpiry:output_type -> messages.OwnerChangeAck
5, // 16: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
11, // 17: messages.ControlPlane.Get:output_type -> messages.GetReply
13, // 0: control_plane_messages.ApplyRequest.messages:type_name -> google.protobuf.Any
13, // 1: control_plane_messages.GetReply.grain:type_name -> google.protobuf.Any
0, // 2: control_plane_messages.ControlPlane.Ping:input_type -> control_plane_messages.Empty
2, // 3: control_plane_messages.ControlPlane.Negotiate:input_type -> control_plane_messages.NegotiateRequest
0, // 4: control_plane_messages.ControlPlane.GetLocalActorIds:input_type -> control_plane_messages.Empty
7, // 5: control_plane_messages.ControlPlane.AnnounceOwnership:input_type -> control_plane_messages.OwnershipAnnounce
9, // 6: control_plane_messages.ControlPlane.Apply:input_type -> control_plane_messages.ApplyRequest
8, // 7: control_plane_messages.ControlPlane.AnnounceExpiry:input_type -> control_plane_messages.ExpiryAnnounce
6, // 8: control_plane_messages.ControlPlane.Closing:input_type -> control_plane_messages.ClosingNotice
10, // 9: control_plane_messages.ControlPlane.Get:input_type -> control_plane_messages.GetRequest
1, // 10: control_plane_messages.ControlPlane.Ping:output_type -> control_plane_messages.PingReply
3, // 11: control_plane_messages.ControlPlane.Negotiate:output_type -> control_plane_messages.NegotiateReply
4, // 12: control_plane_messages.ControlPlane.GetLocalActorIds:output_type -> control_plane_messages.ActorIdsReply
5, // 13: control_plane_messages.ControlPlane.AnnounceOwnership:output_type -> control_plane_messages.OwnerChangeAck
12, // 14: control_plane_messages.ControlPlane.Apply:output_type -> control_plane_messages.ApplyResult
5, // 15: control_plane_messages.ControlPlane.AnnounceExpiry:output_type -> control_plane_messages.OwnerChangeAck
5, // 16: control_plane_messages.ControlPlane.Closing:output_type -> control_plane_messages.OwnerChangeAck
11, // 17: control_plane_messages.ControlPlane.Get:output_type -> control_plane_messages.GetReply
10, // [10:18] is the sub-list for method output_type
2, // [2:10] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name

View File

@@ -4,7 +4,7 @@
// - protoc v6.33.1
// source: control_plane.proto
package messages
package control_plane_messages
import (
context "context"
@@ -19,14 +19,14 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
ControlPlane_GetLocalActorIds_FullMethodName = "/messages.ControlPlane/GetLocalActorIds"
ControlPlane_AnnounceOwnership_FullMethodName = "/messages.ControlPlane/AnnounceOwnership"
ControlPlane_Apply_FullMethodName = "/messages.ControlPlane/Apply"
ControlPlane_AnnounceExpiry_FullMethodName = "/messages.ControlPlane/AnnounceExpiry"
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
ControlPlane_Get_FullMethodName = "/messages.ControlPlane/Get"
ControlPlane_Ping_FullMethodName = "/control_plane_messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/control_plane_messages.ControlPlane/Negotiate"
ControlPlane_GetLocalActorIds_FullMethodName = "/control_plane_messages.ControlPlane/GetLocalActorIds"
ControlPlane_AnnounceOwnership_FullMethodName = "/control_plane_messages.ControlPlane/AnnounceOwnership"
ControlPlane_Apply_FullMethodName = "/control_plane_messages.ControlPlane/Apply"
ControlPlane_AnnounceExpiry_FullMethodName = "/control_plane_messages.ControlPlane/AnnounceExpiry"
ControlPlane_Closing_FullMethodName = "/control_plane_messages.ControlPlane/Closing"
ControlPlane_Get_FullMethodName = "/control_plane_messages.ControlPlane/Get"
)
// ControlPlaneClient is the client API for ControlPlane service.
@@ -362,7 +362,7 @@ func _ControlPlane_Get_Handler(srv interface{}, ctx context.Context, dec func(in
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ControlPlane_ServiceDesc = grpc.ServiceDesc{
ServiceName: "messages.ControlPlane",
ServiceName: "control_plane_messages.ControlPlane",
HandlerType: (*ControlPlaneServer)(nil),
Methods: []grpc.MethodDesc{
{

View File

@@ -1,8 +1,8 @@
syntax = "proto3";
package messages;
package control_plane_messages;
option go_package = "git.k6n.net/go-cart-actor/proto;messages";
option go_package = "git.k6n.net/go-cart-actor/proto/control;control_plane_messages";
import "google/protobuf/any.proto";

View File

@@ -1,232 +0,0 @@
syntax = "proto3";
package messages;
option go_package = "git.k6n.net/go-cart-actor/proto;messages";
import "google/protobuf/any.proto";
import "google/protobuf/timestamp.proto";
message ClearCartRequest {}
message AddItem {
uint32 item_id = 1;
int32 quantity = 2;
int64 price = 3;
int64 orgPrice = 9;
string sku = 4;
string name = 5;
string image = 6;
int32 stock = 7;
int32 tax = 8;
string brand = 13;
string category = 14;
string category2 = 15;
string category3 = 16;
string category4 = 17;
string category5 = 18;
string disclaimer = 10;
string articleType = 11;
string sellerId = 19;
string sellerName = 20;
string country = 21;
string saleStatus = 24;
optional string outlet = 12;
optional string storeId = 22;
optional uint32 parentId = 23;
string cgm = 25;
optional google.protobuf.Timestamp reservationEndTime = 26;
}
message RemoveItem { uint32 Id = 1; }
message ChangeQuantity {
uint32 Id = 1;
int32 quantity = 2;
}
message SetDelivery {
string provider = 1;
repeated uint32 items = 2;
optional PickupPoint pickupPoint = 3;
string country = 4;
string zip = 5;
optional string address = 6;
optional string city = 7;
}
message SetPickupPoint {
uint32 deliveryId = 1;
string id = 2;
optional string name = 3;
optional string address = 4;
optional string city = 5;
optional string zip = 6;
optional string country = 7;
}
message PickupPoint {
string id = 1;
optional string name = 2;
optional string address = 3;
optional string city = 4;
optional string zip = 5;
optional string country = 6;
}
message RemoveDelivery { uint32 id = 1; }
message SetUserId {
string userId = 1;
}
message LineItemMarking {
uint32 id = 1;
uint32 type = 2;
string marking = 3;
}
message RemoveLineItemMarking {
uint32 id = 1;
}
message SubscriptionAdded {
uint32 itemId = 1;
string detailsId = 3;
string orderReference = 4;
}
message PaymentStarted {
string paymentId = 1;
uint32 version = 2;
int64 amount = 3;
string currency = 4;
string provider = 5;
optional string method = 6;
optional google.protobuf.Timestamp startedAt = 7;
}
message PaymentCompleted {
string paymentId = 1;
string status = 2;
int64 amount = 3;
string currency = 4;
optional string processorReference = 5;
optional google.protobuf.Timestamp completedAt = 6;
}
message PaymentDeclined {
string paymentId = 1;
string message = 2;
optional string code = 3;
}
message PaymentEvent {
string paymentId = 1;
string name = 2;
bool success = 3;
google.protobuf.Any data = 4;
}
message ConfirmationViewed {
google.protobuf.Timestamp viewedAt = 1;
}
message CreateCheckoutOrder {
string terms = 1;
string checkout = 2;
string confirmation = 3;
string push = 4;
string validation = 5;
string country = 6;
}
message OrderCreated {
string orderId = 1;
string status = 2;
}
message Noop {
// Intentionally empty - used for ownership acquisition or health pings
}
message InitializeCheckout {
string orderId = 1;
string status = 2;
bool paymentInProgress = 3;
}
message InventoryReserved {
string id = 1;
string status = 2;
optional string message = 3;
}
message AddVoucher {
string code = 1;
int64 value = 2;
repeated string voucherRules = 3;
string description = 4;
}
message RemoveVoucher { uint32 id = 1; }
message UpsertSubscriptionDetails {
optional string id = 1;
string offeringCode = 2;
string signingType = 3;
google.protobuf.Any data = 4;
}
message PreConditionFailed {
string operation = 1;
string error = 2;
google.protobuf.Any input = 3;
}
message GiftcardItem {
int64 value = 1;
string deliveryDate = 2;
string recipient = 3;
string recipientType = 4;
string message = 5;
optional google.protobuf.Any designConfig = 6;
}
message AddGiftcard {
GiftcardItem giftcard = 1;
}
message RemoveGiftcard {
uint32 id = 1;
}
message Mutation {
oneof type {
ClearCartRequest clear_cart = 1;
AddItem add_item = 2;
RemoveItem remove_item = 3;
ChangeQuantity change_quantity = 4;
SetDelivery set_delivery = 5;
SetPickupPoint set_pickup_point = 6;
RemoveDelivery remove_delivery = 7;
SetUserId set_user_id = 8;
LineItemMarking line_item_marking = 9;
RemoveLineItemMarking remove_line_item_marking = 10;
SubscriptionAdded subscription_added = 11;
PaymentDeclined payment_declined = 12;
ConfirmationViewed confirmation_viewed = 13;
CreateCheckoutOrder create_checkout_order = 14;
OrderCreated order_created = 15;
Noop noop = 16;
InitializeCheckout initialize_checkout = 17;
InventoryReserved inventory_reserved = 18;
AddVoucher add_voucher = 19;
RemoveVoucher remove_voucher = 20;
UpsertSubscriptionDetails upsert_subscription_details = 21;
PreConditionFailed pre_condition_failed = 22;
AddGiftcard add_giftcard = 23;
RemoveGiftcard remove_giftcard = 24;
PaymentStarted payment_started = 25;
PaymentCompleted payment_completed = 26;
PaymentEvent payment_event = 27;
}
}