cart and checkout
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-27 19:49:00 +02:00
parent 492f54ff45
commit 528c59bfd3
67 changed files with 3618 additions and 1031 deletions
+7 -1
View File
@@ -19,11 +19,12 @@
MODULE_PATH := git.k6n.net/mats/go-cart-actor MODULE_PATH := git.k6n.net/mats/go-cart-actor
PROTO_DIR := proto PROTO_DIR := proto
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto $(PROTO_DIR)/profile.proto
CART_PROTO_DIR := $(PROTO_DIR)/cart CART_PROTO_DIR := $(PROTO_DIR)/cart
CONTROL_PROTO_DIR := $(PROTO_DIR)/control CONTROL_PROTO_DIR := $(PROTO_DIR)/control
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
ORDER_PROTO_DIR := $(PROTO_DIR)/order ORDER_PROTO_DIR := $(PROTO_DIR)/order
PROFILE_PROTO_DIR := $(PROTO_DIR)/profile
# Allow override: make PROTOC=/path/to/protoc # Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc PROTOC ?= protoc
@@ -88,6 +89,9 @@ protogen: check_tools
--go_out=./proto/order --go_opt=paths=source_relative \ --go_out=./proto/order --go_opt=paths=source_relative \
--go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \ --go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/order.proto $(PROTO_DIR)/order.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/profile --go_opt=paths=source_relative \
$(PROTO_DIR)/profile.proto
@echo "$(GREEN)Protobuf generation complete.$(RESET)" @echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto: clean_proto:
@@ -95,6 +99,8 @@ clean_proto:
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.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)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go @rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
@rm -f $(PROTO_DIR)/order/*_grpc.pb.go $(PROTO_DIR)/order/*.pb.go
@rm -f $(PROTO_DIR)/profile/*.pb.go
@echo "$(GREEN)Clean complete.$(RESET)" @echo "$(GREEN)Clean complete.$(RESET)"
verify_proto: verify_proto:
+3 -1
View File
@@ -24,7 +24,9 @@ func GetDiscovery() discovery.Discovery {
log.Fatalf("Error creating client: %v\n", err) log.Fatalf("Error creating client: %v\n", err)
} }
timeout := int64(30) timeout := int64(30)
return discovery.NewK8sDiscovery(client, v1.ListOptions{ // Scope discovery to this pod's namespace so it only needs a namespaced Role
// (pods: get/list/watch), not a cluster-wide ClusterRole.
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
LabelSelector: "actor-pool=cart", LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout, TimeoutSeconds: &timeout,
}) })
+74 -27
View File
@@ -12,19 +12,21 @@ import (
"strings" "strings"
"time" "time"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/cart"
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp" cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
"git.k6n.net/mats/go-cart-actor/pkg/promotions" "git.k6n.net/mats/go-cart-actor/pkg/promotions"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"git.k6n.net/mats/go-cart-actor/pkg/voucher" "git.k6n.net/mats/go-cart-actor/pkg/voucher"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory" "git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/slask-finder/pkg/messaging" "git.k6n.net/mats/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
) )
@@ -51,13 +53,6 @@ var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD") var redisPassword = os.Getenv("REDIS_PASSWORD")
// getEnv returns the value of the environment variable named by key, or def if unset/empty. // getEnv returns the value of the environment variable named by key, or def if unset/empty.
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// normalizeListenAddr accepts either a bare port ("8080") or a full listen // normalizeListenAddr accepts either a bare port ("8080") or a full listen
// address (":8080", "0.0.0.0:8080") and returns a value usable by http.Server.Addr. // address (":8080", "0.0.0.0:8080") and returns a value usable by http.Server.Addr.
func normalizeListenAddr(v string) string { func normalizeListenAddr(v string) string {
@@ -67,6 +62,14 @@ func normalizeListenAddr(v string) string {
return ":" + v return ":" + v
} }
// getEnv returns the value of the environment variable named by key, or def if unset/empty.
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// loadUCPCartSigner loads the UCP ECDSA signing key from the path specified in // loadUCPCartSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable. // the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPCartSigner() *ucp.SigningConfig { func loadUCPCartSigner() *ucp.SigningConfig {
@@ -144,32 +147,76 @@ func main() {
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]() //inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
// rdb := redis.NewClient(&redis.Options{ rdb := redis.NewClient(&redis.Options{
// Addr: redisAddress, Addr: redisAddress,
// Password: redisPassword, Password: redisPassword,
// DB: 0, DB: 0,
// }) })
// inventoryService, err := inventory.NewRedisInventoryService(rdb) inventoryService, err := inventory.NewRedisInventoryService(rdb)
// if err != nil { if err != nil {
// log.Fatalf("Error creating inventory service: %v\n", err) log.Fatalf("Error creating inventory service: %v\n", err)
// } }
_ = inventoryService
// inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb) inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
// if err != nil { if err != nil {
// log.Fatalf("Error creating inventory reservation service: %v\n", err) log.Fatalf("Error creating inventory reservation service: %v\n", err)
// } }
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
reg.RegisterProcessor( reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error { actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
_, span := tracer.Start(ctx, "Totals and promotions") _, span := tracer.Start(ctx, "Totals and promotions")
defer span.End() defer span.End()
// Clear bypass flags first
for _, v := range g.Vouchers {
if v != nil {
v.BypassedByPromotions = false
}
}
g.UpdateTotals() g.UpdateTotals()
// Evaluate active promotions against the freshly-totalled cart and apply
// any matched actions (e.g. the Volymrabatt volume discount), which adjust
// TotalPrice/TotalDiscount on top of line items and vouchers.
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now())) promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx) results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
// Check if any qualified promotion rule has a coupon_code condition matching our vouchers
hasBypassed := false
for _, res := range results {
if res.Applicable {
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
var codes []string
if s, ok := bc.Value.AsString(); ok {
codes = append(codes, strings.ToLower(s))
} else if arr, ok := bc.Value.AsStringSlice(); ok {
for _, s := range arr {
codes = append(codes, strings.ToLower(s))
}
}
for _, code := range codes {
for _, v := range g.Vouchers {
if v != nil && strings.ToLower(v.Code) == code {
if !v.BypassedByPromotions {
v.BypassedByPromotions = true
hasBypassed = true
}
}
}
}
}
return true
})
}
}
if hasBypassed {
// Re-evaluate with bypassed vouchers
g.UpdateTotals()
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
}
// ApplyResults applies qualifying actions in priority order and records // ApplyResults applies qualifying actions in priority order and records
// every effect — both applied discounts and pending "spend X more for ..." // every effect — both applied discounts and pending "spend X more for ..."
// nudges with their progress — in g.AppliedPromotions. // nudges with their progress — in g.AppliedPromotions.
@@ -287,7 +334,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
otelShutdown, err := setupOTelSDK(ctx) otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil { if err != nil {
log.Fatalf("Unable to start otel %v", err) log.Fatalf("Unable to start otel %v", err)
} }
-117
View File
@@ -1,117 +0,0 @@
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
}
-3
View File
@@ -17,7 +17,6 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/voucher" "git.k6n.net/mats/go-cart-actor/pkg/voucher"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/anypb"
@@ -399,7 +398,6 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
span.SetAttributes(attribute.String("cartid", cartId.String())) span.SetAttributes(attribute.String("cartid", cartId.String()))
hostAttr := attribute.String("other host", ownerHost.Name()) hostAttr := attribute.String("other host", ownerHost.Name())
span.SetAttributes(hostAttr) span.SetAttributes(hostAttr)
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr)) proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil) handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
@@ -419,7 +417,6 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
var ( var (
tracer = otel.Tracer(name) tracer = otel.Tracer(name)
meter = otel.Meter(name) meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
proxyCalls metric.Int64Counter proxyCalls metric.Int64Counter
) )
+13 -3
View File
@@ -12,6 +12,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout" messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/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/common"
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator" "github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
@@ -129,7 +130,7 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
// Adyen analogue of the Klarna push. Create the event-sourced // Adyen analogue of the Klarna push. Create the event-sourced
// order now (mirrors KlarnaPushHandler). Idempotent on // order now (mirrors KlarnaPushHandler). Idempotent on
// "checkout-{checkoutId}", so a retried CAPTURE won't duplicate. // "checkout-{checkoutId}", so a retried CAPTURE won't duplicate.
if isSuccess && s.orderClient != nil { if isSuccess {
s.createAdyenOrder(r.Context(), *checkoutId, item) s.createAdyenOrder(r.Context(), *checkoutId, item)
} }
@@ -231,12 +232,21 @@ func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId ca
return return
} }
// Reserve inventory once, guarded by the grain flag so a retried CAPTURE
// does not decrement stock twice. (Same as the Klarna path.)
if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved { if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved {
if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil { if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil {
log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr) log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr)
} else { } else {
// Commit step: release cart reservation since we permanently decremented stock
if s.reservationService != nil {
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
log.Printf("from-checkout: failed to release cart reservation for %s: %v", item.Sku, err)
}
}
}
s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{ s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(), Id: grain.Id.String(),
Status: "success", Status: "success",
+73 -13
View File
@@ -64,7 +64,9 @@ type CheckoutMeta struct {
// //
// If you later need to support different tax rates per line, you can extend // If you later need to support different tax rates per line, you can extend
// CartItem / Delivery to expose that data and propagate it here. // CartItem / Delivery to expose that data and propagate it here.
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) { // tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for
// delivery lines instead of the hardcoded 2500 (25 % as CartItem format).
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) ([]byte, *CheckoutOrder, error) {
if grain == nil { if grain == nil {
return nil, nil, fmt.Errorf("nil grain") return nil, nil, fmt.Errorf("nil grain")
} }
@@ -106,12 +108,32 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
}) })
} }
hasFreeShipping := false
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
total := cart.NewPrice() total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice) total.Add(*grain.CartState.TotalPrice)
// Delivery lines // Delivery lines — use the configured default tax rate for the purchase country.
defaultKlarnaRate := defaultKlarnaTaxRate(tp, country)
for _, d := range grain.Deliveries { for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 { if d == nil {
continue
}
unitPrice := d.Price.IncVat
taxAmount := d.Price.TotalVat()
if hasFreeShipping {
unitPrice = 0
taxAmount = 0
}
if unitPrice <= 0 && !hasFreeShipping {
continue continue
} }
//total.Add(d.Price) //total.Add(d.Price)
@@ -120,11 +142,11 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
Reference: d.Provider, Reference: d.Provider,
Name: "Delivery", Name: "Delivery",
Quantity: 1, Quantity: 1,
UnitPrice: int(d.Price.IncVat), UnitPrice: int(unitPrice),
TaxRate: 2500, TaxRate: defaultKlarnaRate,
QuantityUnit: "st", QuantityUnit: "st",
TotalAmount: int(d.Price.IncVat), TotalAmount: int(unitPrice),
TotalTaxAmount: int(d.Price.TotalVat()), TotalTaxAmount: int(taxAmount),
}) })
} }
@@ -154,6 +176,24 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
return payload, order, nil return payload, order, nil
} }
// defaultKlarnaTaxRate returns the default tax rate for the purchase country, in Klarna
// format (percent x 100, e.g. 2500 = 25.00 %). When tp is nil, falls back to 2500 (25 %).
func defaultKlarnaTaxRate(tp cart.TaxProvider, country string) int {
if tp == nil {
return 2500
}
return tp.DefaultTaxRate(country) * 100
}
// defaultAdyenTaxRate returns the default tax rate for the purchase country, in Adyen
// format (raw percent, e.g. 25 = 25 %). When tp is nil, falls back to 25.
func defaultAdyenTaxRate(tp cart.TaxProvider, country string) int64 {
if tp == nil {
return 25
}
return int64(tp.DefaultTaxRate(country))
}
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta { func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
host := getOriginalHost(r) host := getOriginalHost(r)
country := getCountryFromHost(host) country := getCountryFromHost(host)
@@ -171,7 +211,7 @@ func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
} }
} }
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) { func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
if grain == nil { if grain == nil {
return nil, fmt.Errorf("nil grain") return nil, fmt.Errorf("nil grain")
} }
@@ -204,20 +244,40 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
TaxPercentage: common.PtrInt64(int64(it.Tax)), TaxPercentage: common.PtrInt64(int64(it.Tax)),
}) })
} }
hasFreeShipping := false
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
total := cart.NewPrice() total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice) total.Add(*grain.CartState.TotalPrice)
// Delivery lines // Delivery lines — use the configured default tax rate for the purchase country.
defaultAdyenRate := defaultAdyenTaxRate(tp, country)
for _, d := range grain.Deliveries { for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 { if d == nil {
continue
}
amountIncTax := d.Price.IncVat
amountExTax := d.Price.ValueExVat()
if hasFreeShipping {
amountIncTax = 0
amountExTax = 0
}
if amountIncTax <= 0 && !hasFreeShipping {
continue continue
} }
lineItems = append(lineItems, adyenCheckout.LineItem{ lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(1), Quantity: common.PtrInt64(1),
AmountIncludingTax: common.PtrInt64(d.Price.IncVat), AmountIncludingTax: common.PtrInt64(amountIncTax),
Description: common.PtrString("Delivery"), Description: common.PtrString("Delivery"),
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()), AmountExcludingTax: common.PtrInt64(amountExTax),
TaxPercentage: common.PtrInt64(25), TaxPercentage: common.PtrInt64(defaultAdyenRate),
}) })
} }
+3 -1
View File
@@ -24,7 +24,9 @@ func GetDiscovery() discovery.Discovery {
log.Fatalf("Error creating client: %v\n", err) log.Fatalf("Error creating client: %v\n", err)
} }
timeout := int64(30) timeout := int64(30)
return discovery.NewK8sDiscovery(client, v1.ListOptions{ // Scope discovery to this pod's namespace so it only needs a namespaced Role
// (pods: get/list/watch), not a cluster-wide ClusterRole.
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
LabelSelector: "actor-pool=checkout", LabelSelector: "actor-pool=checkout",
TimeoutSeconds: &timeout, TimeoutSeconds: &timeout,
}) })
+24 -64
View File
@@ -193,6 +193,18 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway", logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway",
"err", err, "checkoutId", grain.Id.String()) "err", err, "checkoutId", grain.Id.String())
invStatus = "failed" invStatus = "failed"
} else {
// Commit step: successfully decremented stock permanently, now release the temporary cart reservations
if s.reservationService != nil {
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationService.ReleaseForCart(r.Context(), inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
logger.WarnContext(r.Context(), "klarna push: failed to release cart reservation", "sku", item.Sku, "err", err)
}
}
}
} }
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{ s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(), Id: grain.Id.String(),
@@ -209,23 +221,13 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
CompletedAt: timestamppb.Now(), CompletedAt: timestamppb.Now(),
}) })
// ── Create the event-sourced order grain (dual-write with AMQP) ──── // ── Create the event-sourced order grain (HTTP with AMQP fallback) ────
if s.orderClient != nil {
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil { if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
// Non-fatal: the checkout grain is updated, the order can be // Non-fatal: the checkout grain is updated, the order can be
// created on retry (idempotency key guards against duplicates). // created on retry (idempotency key guards against duplicates).
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push", logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
"err", orderErr, "checkoutId", grain.Id.String()) "err", orderErr, "checkoutId", grain.Id.String())
} }
}
// ── Dual-write: AMQP order-queue for legacy consumers ───────────────
if s.orderHandler != nil {
legacyBody, _ := buildLegacyOrderJSON(grain, order, "klarna", orderId)
if pubErr := s.orderHandler.OrderCompleted(legacyBody); pubErr != nil {
logger.WarnContext(r.Context(), "order-queue publish failed", "err", pubErr)
}
}
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId) err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil { if err != nil {
@@ -264,59 +266,7 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
}) })
} }
// buildLegacyOrderJSON builds the go-order-manager compatible JSON for AMQP
// dual-write, so existing downstream consumers on the order-queue still receive
// order events during the migration period.
func buildLegacyOrderJSON(grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string, orderId string) ([]byte, error) {
if grain.CartState == nil {
return nil, fmt.Errorf("buildLegacyOrderJSON: checkout %s has no cart state", grain.Id)
}
type legacyLine struct {
Reference string `json:"reference"`
Name string `json:"name"`
Quantity int `json:"quantity"`
UnitPrice int `json:"unit_price"`
TaxRate int `json:"tax_rate"`
TotalAmount int `json:"total_amount"`
TotalTaxAmount int `json:"total_tax_amount"`
}
type legacyOrder struct {
ID string `json:"order_id"`
PurchaseCountry string `json:"purchase_country"`
PurchaseCurrency string `json:"purchase_currency"`
Locale string `json:"locale"`
OrderAmount int `json:"order_amount"`
OrderTaxAmount int `json:"order_tax_amount"`
OrderLines []legacyLine `json:"order_lines"`
}
lines := make([]legacyLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lines = append(lines, legacyLine{
Reference: it.Sku,
Name: it.Meta.Name,
Quantity: int(it.Quantity),
UnitPrice: int(it.Price.IncVat),
TaxRate: it.Tax,
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
})
}
lo := legacyOrder{
ID: orderId,
PurchaseCountry: klarnaOrder.PurchaseCountry,
PurchaseCurrency: klarnaOrder.PurchaseCurrency,
Locale: klarnaOrder.Locale,
OrderAmount: klarnaOrder.OrderAmount,
OrderTaxAmount: klarnaOrder.OrderTaxAmount,
OrderLines: lines,
}
return json.Marshal(lo)
}
func getLocationId(item *cart.CartItem) inventory.LocationID { func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" { if item.StoreId == nil || *item.StoreId == "" {
@@ -325,10 +275,20 @@ func getLocationId(item *cart.CartItem) inventory.LocationID {
return inventory.LocationID(*item.StoreId) return inventory.LocationID(*item.StoreId)
} }
func shouldTrackInventory(item *cart.CartItem) bool {
if item.DropShip {
return false
}
if item.InventoryTracked {
return true
}
return item.Cgm == "55010"
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest { func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest var requests []inventory.ReserveRequest
for _, item := range items { for _, item := range items {
if item == nil { if item == nil || !shouldTrackInventory(item) {
continue continue
} }
requests = append(requests, inventory.ReserveRequest{ requests = append(requests, inventory.ReserveRequest{
+40 -5
View File
@@ -12,14 +12,16 @@ import (
"git.k6n.net/mats/go-cart-actor/internal/ucp" "git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"github.com/adyen/adyen-go-api-library/v21/src/adyen" "github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common" "github.com/adyen/adyen-go-api-library/v21/src/common"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
) )
@@ -49,6 +51,27 @@ var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD") var redisPassword = os.Getenv("REDIS_PASSWORD")
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081 var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
// selectTaxProvider picks the tax provider from the environment.
// Default is NordicTaxProvider with SE as the default country.
// Set TAX_PROVIDER=static for dev and tests (no country awareness).
func selectTaxProvider() cart.TaxProvider {
switch os.Getenv("TAX_PROVIDER") {
case "static":
return cart.NewStaticTaxProvider()
case "nordic":
return cart.NewNordicTaxProvider(envOrDefault("TAX_DEFAULT_COUNTRY", "SE"))
default:
return cart.NewNordicTaxProvider(envOrDefault("TAX_DEFAULT_COUNTRY", "SE"))
}
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in // loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable. // the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPCheckoutSigner() *ucp.SigningConfig { func loadUCPCheckoutSigner() *ucp.SigningConfig {
@@ -90,12 +113,17 @@ func main() {
log.Fatalf("Error creating inventory service: %v\n", err) log.Fatalf("Error creating inventory service: %v\n", err)
} }
reservationService, err := inventory.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Error creating reservation service: %v\n", err)
}
checkoutDir := os.Getenv("CHECKOUT_DIR") checkoutDir := os.Getenv("CHECKOUT_DIR")
if checkoutDir == "" { if checkoutDir == "" {
checkoutDir = "data" checkoutDir = "data"
} }
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg) diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
var syncedServer *CheckoutPoolServer
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{ poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
MutationRegistry: reg, MutationRegistry: reg,
Storage: diskStorage, Storage: diskStorage,
@@ -113,6 +141,11 @@ func main() {
return ret, nil return ret, nil
}, },
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error { Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
ctx := context.Background()
state, err := grain.GetCurrentState()
if err == nil && state != nil {
syncedServer.releaseCartReservations(ctx, state)
}
return nil return nil
}, },
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) { SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
@@ -153,8 +186,10 @@ func main() {
log.Fatalf("failed to declare order queue: %v", err) log.Fatalf("failed to declare order queue: %v", err)
} }
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler) syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
syncedServer.inventoryService = inventoryService syncedServer.inventoryService = inventoryService
syncedServer.reservationService = reservationService
syncedServer.taxProvider = selectTaxProvider()
mux := http.NewServeMux() mux := http.NewServeMux()
debugMux := http.NewServeMux() debugMux := http.NewServeMux()
@@ -174,7 +209,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
otelShutdown, err := setupOTelSDK(ctx) otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil { if err != nil {
log.Fatalf("Unable to start otel %v", err) log.Fatalf("Unable to start otel %v", err)
} }
+82 -10
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/mats/go-cart-actor/pkg/checkout"
@@ -27,6 +28,16 @@ type settledPayment struct {
// selections into from-checkout order lines. Shared by every provider so the // selections into from-checkout order lines. Shared by every provider so the
// order payload is built the same way no matter who settled the payment. // order payload is built the same way no matter who settled the payment.
func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
hasFreeShipping := false
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries)) lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
for _, it := range grain.CartState.Items { for _, it := range grain.CartState.Items {
if it == nil { if it == nil {
@@ -38,11 +49,19 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
Name: it.Meta.Name, Name: it.Meta.Name,
Quantity: int32(it.Quantity), Quantity: int32(it.Quantity),
UnitPrice: it.Price.IncVat, UnitPrice: it.Price.IncVat,
TaxRate: int32(it.Tax), // CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
TaxRate: int32(it.Tax / 100),
}) })
} }
for _, d := range grain.Deliveries { for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 { if d == nil {
continue
}
unitPrice := d.Price.IncVat
if hasFreeShipping {
unitPrice = 0
}
if unitPrice <= 0 && !hasFreeShipping {
continue continue
} }
lines = append(lines, OrderLine{ lines = append(lines, OrderLine{
@@ -50,10 +69,35 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
Sku: d.Provider, Sku: d.Provider,
Name: "Delivery", Name: "Delivery",
Quantity: 1, Quantity: 1,
UnitPrice: d.Price.IncVat, UnitPrice: unitPrice,
TaxRate: 2500, TaxRate: 25,
}) })
} }
// Reflected applied promotions as negative discount lines
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Pending {
continue
}
discountVal := int64(0)
if ap.Discount != nil {
discountVal = ap.Discount.IncVat
}
if discountVal <= 0 {
continue
}
lines = append(lines, OrderLine{
Reference: "promo-" + ap.PromotionId,
Sku: "promo-" + ap.PromotionId,
Name: "Promotion: " + ap.Name,
Quantity: 1,
UnitPrice: -discountVal,
TaxRate: 25, // default standard tax rate for promotion discount
})
}
}
return lines return lines
} }
@@ -64,8 +108,10 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
// //
// It is idempotent: the order service dedupes on "checkout-{checkoutId}", so // It is idempotent: the order service dedupes on "checkout-{checkoutId}", so
// repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the // repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the
// existing order rather than creating a duplicate. Callers should treat a // existing order rather than creating a duplicate.
// non-nil error as non-fatal and retry on the next callback. //
// If the HTTP call fails or is not configured, it falls back to publishing the
// exact same payload to RabbitMQ via s.orderHandler.OrderCompleted(body).
func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) { func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) {
if grain.CartState == nil { if grain.CartState == nil {
return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id) return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id)
@@ -96,11 +142,10 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
req.Payment.Reference = pay.Reference req.Payment.Reference = pay.Reference
req.Payment.Amount = pay.Amount req.Payment.Amount = pay.Amount
var createErr error
if s.orderClient != nil {
result, err := s.orderClient.CreateOrder(ctx, req, "") result, err := s.orderClient.CreateOrder(ctx, req, "")
if err != nil { if err == nil {
return nil, err
}
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{ _ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
OrderId: result.OrderId, OrderId: result.OrderId,
Status: "completed", Status: "completed",
@@ -108,3 +153,30 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
}) })
return result, nil return result, nil
} }
createErr = err
logger.WarnContext(ctx, "from-checkout HTTP call failed; falling back to AMQP", "err", err, "checkoutId", grain.Id.String())
}
// Fallback to AMQP
if s.orderHandler != nil {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal order fallback: %w", err)
}
if pubErr := s.orderHandler.OrderCompleted(body); pubErr != nil {
if createErr != nil {
return nil, fmt.Errorf("AMQP fallback failed: %w (HTTP error: %v)", pubErr, createErr)
}
return nil, fmt.Errorf("AMQP fallback failed: %w", pubErr)
}
logger.InfoContext(ctx, "order request published to AMQP successfully as fallback", "checkoutId", grain.Id.String())
return &CreateOrderResult{
OrderId: "queued-" + grain.Id.String(),
}, nil
}
if createErr != nil {
return nil, createErr
}
return nil, fmt.Errorf("neither order client nor AMQP order handler configured")
}
-117
View File
@@ -1,117 +0,0 @@
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
}
+26 -6
View File
@@ -52,6 +52,8 @@ type CheckoutPoolServer struct {
orderClient *OrderClient orderClient *OrderClient
orderHandler *AmqpOrderHandler orderHandler *AmqpOrderHandler
inventoryService *inventory.RedisInventoryService inventoryService *inventory.RedisInventoryService
reservationService *inventory.RedisCartReservationService
taxProvider cart.TaxProvider
} }
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer { func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer {
@@ -207,6 +209,10 @@ func (s *CheckoutPoolServer) CancelPaymentHandler(w http.ResponseWriter, r *http
return err return err
} }
if grain, gerr := s.Get(r.Context(), uint64(checkoutId)); gerr == nil && grain != nil {
s.releaseCartReservations(r.Context(), grain)
}
return s.WriteResult(w, result) return s.WriteResult(w, result)
} }
@@ -295,7 +301,7 @@ func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *chec
meta := GetCheckoutMetaFromRequest(r) meta := GetCheckoutMetaFromRequest(r)
payload, _, err := BuildCheckoutOrderPayload(grain, meta) payload, _, err := BuildCheckoutOrderPayload(grain, meta, s.taxProvider)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -431,7 +437,7 @@ func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.
switch payload.Provider { switch payload.Provider {
case "adyen": case "adyen":
meta := GetCheckoutMetaFromRequest(r) meta := GetCheckoutMetaFromRequest(r)
sessionData, err := BuildAdyenCheckoutSession(grain, meta) sessionData, err := BuildAdyenCheckoutSession(grain, meta, s.taxProvider)
if err != nil { if err != nil {
logger.Error("unable to build adyen session", "error", err) logger.Error("unable to build adyen session", "error", err)
return err return err
@@ -536,10 +542,10 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler))) handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler))) handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler))) handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler)))
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler))) handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler))) handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler))) handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler))) handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler))) //handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler))) //handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
@@ -547,3 +553,17 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler) handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
} }
func (s *CheckoutPoolServer) releaseCartReservations(ctx context.Context, grain *checkout.CheckoutGrain) {
if s.reservationService == nil || grain.CartState == nil {
return
}
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
logger.WarnContext(ctx, "failed to release cart reservation", "sku", item.Sku, "err", err)
}
}
}
+1 -2
View File
@@ -25,10 +25,9 @@ func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
if !ok { if !ok {
centralStock = 0 centralStock = 0
} }
sku, ok := item.GetStringFieldValue("sku") sku, ok := item.GetStringFieldValue("sku")
if !ok { if !ok {
log.Printf("unable to parse central stock for item %s: %v", sku) log.Printf("unable to parse central stock for item: sku missing")
centralStock = 0 centralStock = 0
} else { } else {
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock)) s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
+22 -105
View File
@@ -3,13 +3,9 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"hash/fnv"
"log/slog"
"sync" "sync"
"time" "time"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
amqp "github.com/rabbitmq/amqp091-go" amqp "github.com/rabbitmq/amqp091-go"
) )
@@ -73,135 +69,56 @@ func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) erro
return err return err
} }
// This ingester lets the order service supersede the legacy go-order-manager: // ingestOrderFromQueue records one order from the queue. It uses the exact same
// it consumes the same "order-queue" the Klarna/Adyen checkout publishes to and // idempotent business logic as the HTTP endpoint.
// records each completed order as an event-sourced grain. Those orders are func ingestOrderFromQueue(ctx context.Context, s *server, body []byte) error {
// already paid by the external processor, so we record payment with provider var req fromCheckoutReq
// "legacy" (place -> authorize -> capture) rather than charging again. if err := json.Unmarshal(body, &req); err != nil {
// legacyOrder is the subset of go-order-manager's Order JSON we need.
type legacyOrder struct {
ID string `json:"order_id"`
PurchaseCountry string `json:"purchase_country"`
PurchaseCurrency string `json:"purchase_currency"`
Locale string `json:"locale"`
OrderAmount int64 `json:"order_amount"`
OrderTaxAmount int64 `json:"order_tax_amount"`
OrderLines []legacyLine `json:"order_lines"`
Customer *legacyPerson `json:"customer,omitempty"`
BillingAddress *legacyAddr `json:"billing_address,omitempty"`
}
type legacyLine struct {
Reference string `json:"reference"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unit_price"`
TaxRate int32 `json:"tax_rate"`
TotalAmount int64 `json:"total_amount"`
TotalTaxAmount int64 `json:"total_tax_amount"`
}
type legacyPerson struct {
Email string `json:"email,omitempty"`
}
type legacyAddr struct {
Email string `json:"email,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
}
// legacyOrderID maps a legacy string order id to a stable grain id, so a
// redelivered message resolves to the same grain (PlaceOrder then no-ops).
func legacyOrderID(ref string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(ref))
id := h.Sum64()
if id == 0 {
id = 1
}
return id
}
// ingestLegacyOrder records one legacy order as an event-sourced grain. It is
// idempotent: if the order is already placed, PlaceOrder is rejected and we
// stop (the order already exists). Testable without a broker.
func ingestLegacyOrder(ctx context.Context, app *orderedApplier, body []byte) error {
var lo legacyOrder
if err := json.Unmarshal(body, &lo); err != nil {
return err return err
} }
if lo.ID == "" || len(lo.OrderLines) == 0 { if len(req.Lines) == 0 {
return nil // nothing actionable return nil // nothing actionable
} }
id := legacyOrderID(lo.ID)
ms := time.Now().UnixMilli()
po := &messages.PlaceOrder{ _, _, _, _, runErr, err := s.createOrderFromCheckoutInternal(ctx, &req)
OrderReference: lo.ID, if err != nil {
Currency: lo.PurchaseCurrency, return err
Locale: lo.Locale,
Country: lo.PurchaseCountry,
TotalAmount: lo.OrderAmount,
TotalTax: lo.OrderTaxAmount,
PlacedAtMs: ms,
} }
if lo.Customer != nil { if runErr != nil {
po.CustomerEmail = lo.Customer.Email return runErr
} }
if po.CustomerEmail == "" && lo.BillingAddress != nil {
po.CustomerEmail = lo.BillingAddress.Email
po.CustomerName = lo.BillingAddress.GivenName + " " + lo.BillingAddress.FamilyName
}
for _, l := range lo.OrderLines {
po.Lines = append(po.Lines, &messages.OrderLine{
Reference: l.Reference, Sku: l.Reference, Name: l.Name,
Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate,
TotalAmount: l.TotalAmount, TotalTax: l.TotalTaxAmount,
})
}
if err := applyOne(ctx, app, id, po); err != nil {
// Already placed (redelivery) or invalid — nothing more to do.
return nil
}
// Record the externally-settled payment so the grain reaches "captured".
ref := "legacy-" + lo.ID
_ = applyOne(ctx, app, id, &messages.AuthorizePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
_ = applyOne(ctx, app, id, &messages.CapturePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
return nil return nil
} }
// startLegacyIngest connects to AMQP and consumes "order-queue" until ctx is // startOrderIngest connects to AMQP and consumes "order-queue" until ctx is
// done. Best-effort: a connection failure is logged and ingestion is skipped // done.
// (the HTTP checkout path keeps working regardless). func startOrderIngest(ctx context.Context, amqpURL string, s *server) {
func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier, logger *slog.Logger) {
conn, err := amqp.Dial(amqpURL) conn, err := amqp.Dial(amqpURL)
if err != nil { if err != nil {
logger.Warn("legacy order ingest disabled: amqp dial failed", "err", err) s.logger.Warn("order ingest disabled: amqp dial failed", "err", err)
return return
} }
ch, err := conn.Channel() ch, err := conn.Channel()
if err != nil { if err != nil {
logger.Warn("legacy order ingest disabled: channel failed", "err", err) s.logger.Warn("order ingest disabled: channel failed", "err", err)
_ = conn.Close() _ = conn.Close()
return return
} }
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil) q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
if err != nil { if err != nil {
logger.Warn("legacy order ingest disabled: queue declare failed", "err", err) s.logger.Warn("order ingest disabled: queue declare failed", "err", err)
_ = ch.Close() _ = ch.Close()
_ = conn.Close() _ = conn.Close()
return return
} }
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil) msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
if err != nil { if err != nil {
logger.Warn("legacy order ingest disabled: consume failed", "err", err) s.logger.Warn("order ingest disabled: consume failed", "err", err)
_ = ch.Close() _ = ch.Close()
_ = conn.Close() _ = conn.Close()
return return
} }
logger.Info("ingesting legacy orders from order-queue") s.logger.Info("ingesting orders from order-queue")
go func() { go func() {
defer conn.Close() defer conn.Close()
defer ch.Close() defer ch.Close()
@@ -211,11 +128,11 @@ func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier,
return return
case d, ok := <-msgs: case d, ok := <-msgs:
if !ok { if !ok {
logger.Warn("legacy order ingest: channel closed") s.logger.Warn("order ingest: channel closed")
return return
} }
if err := ingestLegacyOrder(ctx, app, d.Body); err != nil { if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
logger.Error("legacy order ingest failed", "err", err) s.logger.Error("order queue ingest failed", "err", err)
} }
} }
} }
+50 -12
View File
@@ -3,14 +3,19 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log/slog"
"path/filepath"
"testing" "testing"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order" "git.k6n.net/mats/go-cart-actor/pkg/order"
) )
func testApplier(t *testing.T) *orderedApplier { func testServer(t *testing.T) *server {
t.Helper() t.Helper()
reg := actor.NewMutationRegistry() reg := actor.NewMutationRegistry()
order.RegisterMutations(reg) order.RegisterMutations(reg)
@@ -32,23 +37,56 @@ func testApplier(t *testing.T) *orderedApplier {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(pool.Close) t.Cleanup(pool.Close)
return &orderedApplier{pool: pool, storage: storage}
applier := &orderedApplier{pool: pool, storage: storage}
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterEmitHook(freg, nil)
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
def, err := order.EmbeddedFlow("place-and-pay")
if err != nil {
t.Fatal(err)
} }
func TestIngestLegacyOrder(t *testing.T) { idem, err := idempotency.Open(filepath.Join(t.TempDir(), "idem.log"))
app := testApplier(t) if err != nil {
t.Fatal(err)
}
return &server{
pool: pool, applier: applier, storage: storage, reg: reg,
engine: engine, freg: freg,
flows: &flowStore{defs: map[string]*flow.Definition{def.Name: def}},
provider: provider, taxProvider: order.NewStaticTaxProvider(),
defaultFlow: def.Name, idem: idem, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}
func TestIngestOrderFromQueue(t *testing.T) {
s := testServer(t)
ctx := context.Background() ctx := context.Background()
body := []byte(`{ body := []byte(`{
"order_id":"K-1","purchase_currency":"SEK","purchase_country":"se","locale":"sv-SE", "checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
"order_amount":15000,"order_tax_amount":3000, "currency":"SEK","country":"SE","customerEmail":"customer@example.com",
"order_lines":[{"reference":"l1","name":"Widget","quantity":1,"unit_price":15000,"tax_rate":25,"total_amount":15000,"total_tax_amount":3000}] "lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
}`) }`)
if err := ingestLegacyOrder(ctx, app, body); err != nil { if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("ingest: %v", err) t.Fatalf("ingest: %v", err)
} }
id := legacyOrderID("K-1")
g, err := app.Get(ctx, id) // Retrieve the created order grain. The new order ID is recorded in idempotency.Store
id, ok := s.idem.Get("checkout-K-1")
if !ok {
t.Fatalf("expected order ID to be stored in idempotency cache")
}
g, err := s.applier.Get(ctx, id)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -60,10 +98,10 @@ func TestIngestLegacyOrder(t *testing.T) {
} }
// Redelivery must be idempotent — no double capture, no new payment. // Redelivery must be idempotent — no double capture, no new payment.
if err := ingestLegacyOrder(ctx, app, body); err != nil { if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("re-ingest: %v", err) t.Fatalf("re-ingest: %v", err)
} }
g2, _ := app.Get(ctx, id) g2, _ := s.applier.Get(ctx, id)
if g2.CapturedAmount != 15000 { if g2.CapturedAmount != 15000 {
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount) t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
} }
+146 -4
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/flow" "git.k6n.net/mats/go-cart-actor/pkg/flow"
@@ -100,7 +101,7 @@ type fulfillReq struct {
} }
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) { func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r) id, g, ok := s.loadOrder(w, r)
if !ok { if !ok {
return return
} }
@@ -110,11 +111,72 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
return return
} }
fid, _ := order.NewOrderId() fid, _ := order.NewOrderId()
carrier := req.Carrier
trackingNumber := req.TrackingNumber
trackingURI := req.TrackingURI
// Integration with go-shipping:
if carrier == "" && g.CartId != "" {
shippingURL := os.Getenv("SHIPPING_URL")
if shippingURL == "" {
shippingURL = "http://localhost:8080"
}
// Query go-shipping for cached options
url := fmt.Sprintf("%s/api/shipping-options/%s", shippingURL, g.CartId)
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err == nil {
resp, err := http.DefaultClient.Do(httpReq)
if err == nil && resp.StatusCode == http.StatusOK {
var groups []struct {
Type string `json:"type"`
DefaultOption *struct {
BookingInstructions struct {
DeliveryOptionID string `json:"deliveryOptionId"`
ServiceCode string `json:"serviceCode"`
} `json:"bookingInstructions"`
DescriptiveTexts struct {
Checkout struct {
Title string `json:"title"`
} `json:"checkout"`
} `json:"descriptiveTexts"`
} `json:"defaultOption"`
}
if json.NewDecoder(resp.Body).Decode(&groups) == nil && len(groups) > 0 {
g0 := groups[0]
carrier = g0.Type
if g0.DefaultOption != nil {
opt := g0.DefaultOption
if opt.DescriptiveTexts.Checkout.Title != "" {
carrier = opt.DescriptiveTexts.Checkout.Title
}
trackingNumber = "SE-" + opt.BookingInstructions.DeliveryOptionID
trackingURI = "https://www.postnord.se/en/our-tools/track-and-trace?shipmentId=" + trackingNumber
}
}
resp.Body.Close()
}
}
}
if carrier == "" {
carrier = "postnord"
}
if trackingNumber == "" {
trackingNumber = "mock-track-" + fid.String()
}
if trackingURI == "" {
trackingURI = "https://tracking.postnord.com/?id=" + trackingNumber
}
msg := &messages.CreateFulfillment{ msg := &messages.CreateFulfillment{
Id: "f_" + fid.String(), Id: "f_" + fid.String(),
Carrier: req.Carrier, Carrier: carrier,
TrackingNumber: req.TrackingNumber, TrackingNumber: trackingNumber,
TrackingUri: req.TrackingURI, TrackingUri: trackingURI,
AtMs: nowMs(), AtMs: nowMs(),
} }
for _, l := range req.Lines { for _, l := range req.Lines {
@@ -123,6 +185,86 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
s.applyAndRespond(w, r, id, msg) s.applyAndRespond(w, r, id, msg)
} }
type exchangeReq struct {
Reason string `json:"reason,omitempty"`
ReturnLines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"returnLines"`
NewLines []struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
} `json:"newLines"`
}
func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
var req exchangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
exId, _ := order.NewOrderId()
retId, _ := order.NewOrderId()
msg := &messages.RequestExchange{
Id: "ex_" + exId.String(),
ReturnId: "r_" + retId.String(),
Reason: req.Reason,
AtMs: nowMs(),
}
for _, l := range req.ReturnLines {
msg.ReturnLines = append(msg.ReturnLines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
for _, l := range req.NewLines {
msg.NewLines = append(msg.NewLines, &messages.OrderLine{
Reference: l.Reference,
Sku: l.Sku,
Name: l.Name,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice,
TaxRate: l.TaxRate,
TotalAmount: l.TotalAmount,
TotalTax: l.TotalTax,
})
}
s.applyAndRespond(w, r, id, msg)
}
type editDetailsReq struct {
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
ShippingPrice int64 `json:"shippingPrice,omitempty"`
}
func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
var req editDetailsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
msg := &messages.EditOrderDetails{
ShippingAddress: req.ShippingAddress,
BillingAddress: req.BillingAddress,
ShippingPrice: req.ShippingPrice,
AtMs: nowMs(),
}
s.applyAndRespond(w, r, id, msg)
}
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) { func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r) id, _, ok := s.loadOrder(w, r)
if !ok { if !ok {
+44 -53
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -55,101 +56,95 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
return return
} }
// ── Idempotency check ───────────────────────────────────────────── ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req)
// Lock the key for the whole check→create→record sequence so a concurrent
// retry with the same key can't race past the check and create a second
// order. The store is durable, so this also holds across a restart.
if req.IdempotencyKey != "" {
unlock := s.idem.Lock(req.IdempotencyKey)
defer unlock()
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
// The order already exists — return it.
g, err := s.applier.Get(r.Context(), existingID)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err)) writeErr(w, http.StatusInternalServerError, err)
return return
} }
if exists {
writeJSON(w, http.StatusConflict, map[string]any{ writeJSON(w, http.StatusConflict, map[string]any{
"orderId": order.OrderId(existingID).String(), "orderId": order.OrderId(ordID).String(),
"order": g, "order": grain,
"existing": true, "existing": true,
}) })
return return
} }
status := http.StatusCreated
if runErr != nil {
status = http.StatusPaymentRequired
}
writeJSON(w, status, map[string]any{
"orderId": order.OrderId(ordID).String(),
"flow": flowRes,
"order": grain,
})
}
// createOrderFromCheckoutInternal runs the core idempotent checkout-to-order logic.
// It returns (orderID, flowResult, grain, exists, runErr, err).
// - exists is true if the order already existed (idempotency hit).
// - runErr is the flow run error (payment flow failed, e.g. compensating was run).
// - err is a structural/storage error.
func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromCheckoutReq) (uint64, *flow.Result, *order.OrderGrain, bool, error, error) {
if req.IdempotencyKey != "" {
unlock := s.idem.Lock(req.IdempotencyKey)
defer unlock()
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
g, err := s.applier.Get(ctx, existingID)
if err != nil {
return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err)
}
return existingID, nil, g, true, nil, nil
}
} }
// ── Create the order grain ────────────────────────────────────────
id, err := order.NewOrderId() id, err := order.NewOrderId()
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("new order id: %w", err)) return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err)
return
} }
ordID := uint64(id) ordID := uint64(id)
// Build PlaceOrder from the same lineReq format the direct checkout uses. po := buildFromCheckoutPlaceOrder(id, req)
po := buildFromCheckoutPlaceOrder(id, &req)
// Build a passthrough provider for the externally-settled payment.
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount) provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
// Run the place-and-pay flow with the passthrough provider. Since the
// payment is already settled, this records the authorization and capture
// without any external call.
flowName := req.Flow flowName := req.Flow
if flowName == "" { if flowName == "" {
flowName = s.defaultFlow flowName = s.defaultFlow
} }
def, ok := s.flows.get(flowName) def, ok := s.flows.get(flowName)
if !ok { if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName)) return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
return
} }
// Create a per-request flow registry so the authorize/capture actions use
// the passthrough provider for this specific order. RegisterFlowActions
// registers all order lifecycle actions, overriding the default provider.
freg := flow.NewRegistry() freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg) flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, s.applier, provider) order.RegisterFlowActions(freg, s.applier, provider)
order.RegisterEmitHook(freg, nil) order.RegisterEmitHook(freg, nil)
engine := flow.NewEngine(freg, s.logger) engine := flow.NewEngine(freg, s.logger)
st := flow.NewState(ordID, s.logger) st := flow.NewState(ordID, s.logger)
st.Vars[order.PlaceOrderVar] = po st.Vars[order.PlaceOrderVar] = po
res, runErr := engine.Run(r.Context(), def, st) res, runErr := engine.Run(ctx, def, st)
grain, getErr := s.applier.Get(r.Context(), ordID) grain, getErr := s.applier.Get(ctx, ordID)
if getErr != nil { if getErr != nil {
writeErr(w, http.StatusInternalServerError, getErr) return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr)
return
} }
// On failure the flow should have compensated (voided + cancelled). Return
// the failed order with the flow trace so the checkout service can decide
// how to proceed (e.g. alert operator, retry with a new idempotency key).
status := http.StatusCreated
if runErr != nil { if runErr != nil {
status = http.StatusPaymentRequired
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr) s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
} }
// Record the idempotency key durably only on success, so retries (incl. after
// a restart) return the captured order. A failed flow (402) is deliberately
// NOT recorded: the failed grain is terminal, and a retry with the same key
// should re-attempt rather than be handed back a dead order as a 409 hit
// (which the checkout client treats as a successful, existing order).
if req.IdempotencyKey != "" && runErr == nil { if req.IdempotencyKey != "" && runErr == nil {
if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil { if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil {
s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err) s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err)
} }
} }
writeJSON(w, status, map[string]any{ return ordID, res, grain, false, runErr, nil
"orderId": id.String(),
"flow": res,
"order": grain,
})
} }
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder // buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
@@ -169,11 +164,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
var total, totalTax int64 var total, totalTax int64
for _, l := range req.Lines { for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity) lineTotal := l.UnitPrice * int64(l.Quantity)
var lineTax int64 lineTax := order.ComputeTax(lineTotal, int(l.TaxRate))
if l.TaxRate > 0 {
// inc-vat -> tax portion = total * rate / (100 + rate)
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
}
total += lineTotal total += lineTotal
totalTax += lineTax totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{ po.Lines = append(po.Lines, &messages.OrderLine{
+29 -10
View File
@@ -41,6 +41,7 @@ type server struct {
freg *flow.Registry freg *flow.Registry
flows *flowStore flows *flowStore
provider order.PaymentProvider provider order.PaymentProvider
taxProvider order.TaxProvider
defaultFlow string defaultFlow string
dataDir string dataDir string
idem *idempotency.Store idem *idempotency.Store
@@ -134,9 +135,12 @@ func main() {
log.Fatalf("open idempotency store: %v", err) log.Fatalf("open idempotency store: %v", err)
} }
taxProvider := selectTaxProvider()
s := &server{ s := &server{
pool: pool, applier: applier, storage: storage, reg: reg, pool: pool, applier: applier, storage: storage, reg: reg,
engine: engine, freg: freg, flows: flows, provider: provider, engine: engine, freg: freg, flows: flows, provider: provider,
taxProvider: taxProvider,
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger, defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
} }
@@ -156,6 +160,8 @@ func main() {
mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel) mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel)
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn) mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund) mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
mux.HandleFunc("POST /api/orders/{id}/exchanges", s.handleExchange)
mux.HandleFunc("POST /api/orders/{id}/edit", s.handleEditDetails)
// UCP REST adapter — Universal Commerce Protocol order lifecycle. // UCP REST adapter — Universal Commerce Protocol order lifecycle.
orderUCP := ucp.OrderHandler(s.applier) orderUCP := ucp.OrderHandler(s.applier)
@@ -183,10 +189,9 @@ func main() {
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow) mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow) mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
// Ingest legacy checkout orders (Klarna/Adyen → order-queue) so this service // Ingest checkout orders from order-queue (Klarna/Adyen fallback).
// is the single source of orders, superseding go-order-manager.
if amqpURL != "" { if amqpURL != "" {
startLegacyIngest(context.Background(), amqpURL, applier, logger) startOrderIngest(context.Background(), amqpURL, s)
} }
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir) logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
@@ -292,7 +297,7 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
return return
} }
po := buildPlaceOrder(id, &req) po := s.buildPlaceOrder(id, &req)
st := flow.NewState(uint64(id), s.logger) st := flow.NewState(uint64(id), s.logger)
st.Vars[order.PlaceOrderVar] = po st.Vars[order.PlaceOrderVar] = po
@@ -320,7 +325,8 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
// buildPlaceOrder converts a checkout request into the PlaceOrder event, // buildPlaceOrder converts a checkout request into the PlaceOrder event,
// computing per-line and order totals (inc-vat) from the lines. // computing per-line and order totals (inc-vat) from the lines.
func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder { // Tax amounts are computed using the configured TaxProvider.
func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
po := &messages.PlaceOrder{ po := &messages.PlaceOrder{
OrderReference: req.OrderReference, OrderReference: req.OrderReference,
CartId: req.CartId, CartId: req.CartId,
@@ -336,11 +342,7 @@ func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
var total, totalTax int64 var total, totalTax int64
for _, l := range req.Lines { for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity) lineTotal := l.UnitPrice * int64(l.Quantity)
var lineTax int64 lineTax := s.taxProvider.ComputeTax(lineTotal, int(l.TaxRate))
if l.TaxRate > 0 {
// inc-vat -> tax portion = total * rate / (100 + rate)
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
}
total += lineTotal total += lineTotal
totalTax += lineTax totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{ po.Lines = append(po.Lines, &messages.OrderLine{
@@ -450,10 +452,27 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
// --- helpers -------------------------------------------------------------- // --- helpers --------------------------------------------------------------
// selectTaxProvider picks the tax provider from the environment. Default is
// NordicTaxProvider with SE as the default country (matching the current
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
// (no country awareness).
func selectTaxProvider() order.TaxProvider {
provider := os.Getenv("TAX_PROVIDER")
switch provider {
case "static":
return order.NewStaticTaxProvider()
case "nordic":
return order.NewNordicTaxProvider(envOr("TAX_DEFAULT_COUNTRY", "SE"))
default:
return order.NewNordicTaxProvider(envOr("TAX_DEFAULT_COUNTRY", "SE"))
}
}
// selectProvider picks the payment provider from the environment. Default is // selectProvider picks the payment provider from the environment. Default is
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for // the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override). // real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
func selectProvider(logger *slog.Logger) order.PaymentProvider { func selectProvider(logger *slog.Logger) order.PaymentProvider {
if os.Getenv("PAYMENT_PROVIDER") == "stripe" { if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
key := os.Getenv("STRIPE_SECRET_KEY") key := os.Getenv("STRIPE_SECRET_KEY")
if key == "" { if key == "" {
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"log"
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// GetDiscovery returns the k8s pod-watcher that finds peer profile replicas, or
// nil when POD_IP is unset (single-node / local dev — clustering disabled). The
// watch is scoped to this pod's own namespace so it only needs a namespaced Role
// (pods: get/list/watch), not a cluster-wide role.
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.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
LabelSelector: "actor-pool=profile",
TimeoutSeconds: &timeout,
})
}
// UseDiscovery starts the watch loop that adds/removes peer hosts from the pool
// as profile replicas become ready or go away. A nil discovery (POD_IP unset)
// logs and returns, leaving the pool in single-node mode.
func UseDiscovery(pool discovery.DiscoveryTarget) {
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())
}
+65 -7
View File
@@ -17,9 +17,39 @@ import (
"git.k6n.net/mats/go-cart-actor/internal/ucp" "git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/profile" "git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
) )
// buildAuthStorage selects the credential store and login limiter that back the
// customer-auth surface. With REDIS_ADDRESS set it uses shared Redis storage so
// the service scales horizontally; otherwise it falls back to the file-backed
// store and an in-memory limiter (single-instance / local dev). A Redis failure
// at startup is fatal rather than silently degrading to per-replica state.
func buildAuthStorage(ctx context.Context, profileDir string) (customerauth.Credentials, customerauth.Limiter) {
if addr := os.Getenv("REDIS_ADDRESS"); addr != "" {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: os.Getenv("REDIS_PASSWORD"),
DB: 0,
})
store, err := customerauth.NewRedisCredentialStore(ctx, rdb)
if err != nil {
log.Fatalf("Error connecting customer-auth credential store to Redis at %s: %v", addr, err)
}
log.Printf("customer-auth: using shared Redis storage at %s", addr)
return store, customerauth.NewRedisLoginLimiter(rdb, 0, 0)
}
credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json"))
if err != nil {
log.Fatalf("Error loading credential store: %v", err)
}
log.Print("customer-auth: REDIS_ADDRESS unset — using file credential store + in-memory limiter (single-instance only)")
return credStore, customerauth.NewLoginLimiter(0, 0)
}
// authSecret returns the HMAC key for customer session cookies. It reads // authSecret returns the HMAC key for customer session cookies. It reads
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and // CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
// warns that issued sessions will not survive a restart (fine for dev). // warns that issued sessions will not survive a restart (fine for dev).
@@ -86,6 +116,13 @@ func main() {
// Destroy is an optional grain-eviction cleanup hook; profiles need none // Destroy is an optional grain-eviction cleanup hook; profiles need none
// (disk storage persists via its own loop), so it's left unset — purge() // (disk storage persists via its own loop), so it's left unset — purge()
// skips a nil Destroy. // skips a nil Destroy.
// SpawnHost makes the pool cluster-aware: when a grain is owned by a peer
// replica, the pool proxies to it over gRPC :1337 (control) + HTTP :8080
// (requests). With POD_IP unset and no discovered peers this is never
// invoked, so the service still runs fine single-node.
SpawnHost: func(host string) (actor.Host[profile.ProfileGrain], error) {
return proxy.NewRemoteHost[profile.ProfileGrain](host)
},
TTL: 10 * time.Minute, TTL: 10 * time.Minute,
PoolSize: 65535, PoolSize: 65535,
Hostname: podIp, Hostname: podIp,
@@ -96,19 +133,40 @@ func main() {
log.Fatalf("Error creating profile pool: %v\n", err) log.Fatalf("Error creating profile pool: %v\n", err)
} }
// Clustering control plane: gRPC server on :1337 serves peer pools'
// ownership announcements and remote Apply/Get calls; UseDiscovery watches
// for sibling profile pods (label actor-pool=profile) and wires them into the
// pool. Both are no-ops in single-node mode (POD_IP unset → nil discovery).
grpcSrv, err := actor.NewControlServer[profile.ProfileGrain](actor.DefaultServerConfig(), pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
UseDiscovery(pool)
mux := http.NewServeMux() mux := http.NewServeMux()
debugMux := http.NewServeMux() debugMux := http.NewServeMux()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
otelShutdown, err := setupOTelSDK(ctx) otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil { if err != nil {
log.Fatalf("Unable to start otel %v", err) log.Fatalf("Unable to start otel %v", err)
} }
// Customer auth storage and failed-login limiter.
// Credentials and the failed-login limiter use shared Redis storage when
// REDIS_ADDRESS is set (so the service scales horizontally), and fall back to
// the file store + in-memory limiter for single-instance / local dev. Session
// and verify/reset tokens are stateless HMAC values, so a shared
// CUSTOMER_AUTH_SECRET is all they need to work across replicas.
credStore, limiter := buildAuthStorage(ctx, profileDir)
// UCP Customer REST adapter // UCP Customer REST adapter
customerUCP := ucp.CustomerHandler(pool) auditLogPath := filepath.Join(profileDir, "audit.log")
customerUCP := ucp.CustomerHandler(pool, auditLogPath, credStore)
if signer := loadUCPProfileSigner(); signer != nil { if signer := loadUCPProfileSigner(); signer != nil {
customerUCP = ucp.WithSigning(customerUCP, signer) customerUCP = ucp.WithSigning(customerUCP, signer)
log.Print("ucp customer signing enabled") log.Print("ucp customer signing enabled")
@@ -119,11 +177,11 @@ func main() {
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP)) mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
// Customer auth: password signup/login + session cookies + identity linking. // Customer auth: password signup/login + session cookies + identity linking.
credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json")) authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{
if err != nil { Limiter: limiter,
log.Fatalf("Error loading credential store: %v\n", err) BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
} RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true",
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0).Handler() }).Handler()
mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler)) mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
+41 -3
View File
@@ -1,6 +1,7 @@
package customerauth package customerauth
import ( import (
"context"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -73,11 +74,12 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("load: %v", err) t.Fatalf("load: %v", err)
} }
if err := store.Register("User@Example.com", 42, "hash1", "ts"); err != nil { ctx := context.Background()
if err := store.Register(ctx, "User@Example.com", 42, "hash1", "ts"); err != nil {
t.Fatalf("register: %v", err) t.Fatalf("register: %v", err)
} }
// Duplicate (case-insensitive) is rejected. // Duplicate (case-insensitive) is rejected.
if err := store.Register("user@example.com", 99, "hash2", "ts"); err != ErrEmailExists { if err := store.Register(ctx, "user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
t.Fatalf("duplicate: got %v, want ErrEmailExists", err) t.Fatalf("duplicate: got %v, want ErrEmailExists", err)
} }
// Reload from disk and confirm the record persisted. // Reload from disk and confirm the record persisted.
@@ -85,7 +87,7 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("reload: %v", err) t.Fatalf("reload: %v", err)
} }
rec, ok := reloaded.Get("USER@EXAMPLE.COM") rec, ok := reloaded.Get(ctx, "USER@EXAMPLE.COM")
if !ok { if !ok {
t.Fatal("record not found after reload") t.Fatal("record not found after reload")
} }
@@ -93,3 +95,39 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
t.Fatalf("unexpected record: %+v", rec) t.Fatalf("unexpected record: %+v", rec)
} }
} }
func TestStoreDelete(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials.json")
store, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("load: %v", err)
}
ctx := context.Background()
if err := store.Register(ctx, "user@example.com", 42, "hash1", "ts"); err != nil {
t.Fatalf("register: %v", err)
}
// Delete
ok, err := store.Delete(ctx, "USER@example.com")
if err != nil {
t.Fatalf("delete failed: %v", err)
}
if !ok {
t.Fatal("expected delete to report email was found")
}
// Confirm not found
if _, ok := store.Get(ctx, "user@example.com"); ok {
t.Fatal("record still exists after delete")
}
// Reload from disk and verify it's gone
reloaded, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if _, ok := reloaded.Get(ctx, "user@example.com"); ok {
t.Fatal("record still exists after reload")
}
}
+115
View File
@@ -0,0 +1,115 @@
package customerauth
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestPurposeTokenRoundTrip(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.IssuePurpose(purposePasswordReset, "user@example.com", time.Hour)
sub, err := s.ParsePurpose(purposePasswordReset, tok)
if err != nil {
t.Fatalf("parse: %v", err)
}
if sub != "user@example.com" {
t.Fatalf("got subject %q, want user@example.com", sub)
}
}
func TestPurposeTokenWrongPurposeRejected(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
// A verify token must not be accepted as a reset token.
if _, err := s.ParsePurpose(purposePasswordReset, tok); err != ErrInvalidToken {
t.Fatalf("got %v, want ErrInvalidToken", err)
}
}
func TestPurposeTokenExpiredAndTampered(t *testing.T) {
s := NewSigner([]byte("test-secret"))
expired := s.IssuePurpose(purposeVerifyEmail, "user@example.com", -time.Second)
if _, err := s.ParsePurpose(purposeVerifyEmail, expired); err != ErrExpiredToken {
t.Fatalf("expired: got %v, want ErrExpiredToken", err)
}
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
if _, err := s.ParsePurpose(purposeVerifyEmail, tok+"x"); err != ErrInvalidToken {
t.Fatalf("tampered: got %v, want ErrInvalidToken", err)
}
}
func TestLoginLimiterLocksOutAndResets(t *testing.T) {
ctx := context.Background()
l := NewLoginLimiter(3, time.Minute)
const key = "user@example.com"
for i := range 3 {
if ok, _ := l.Allowed(ctx, key); !ok {
t.Fatalf("locked out early after %d failures", i)
}
l.RecordFailure(ctx, key)
}
ok, retry := l.Allowed(ctx, key)
if ok {
t.Fatal("expected lockout after 3 failures")
}
if retry <= 0 {
t.Fatalf("expected positive retry-after, got %v", retry)
}
// A successful auth clears the history.
l.Reset(ctx, key)
if ok, _ := l.Allowed(ctx, key); !ok {
t.Fatal("expected reset to clear lockout")
}
}
func TestLoginLimiterNilIsNoop(t *testing.T) {
ctx := context.Background()
var l *LoginLimiter
if ok, _ := l.Allowed(ctx, "x"); !ok {
t.Fatal("nil limiter should allow")
}
l.RecordFailure(ctx, "x") // must not panic
l.Reset(ctx, "x") // must not panic
}
func TestStoreMarkVerifiedAndUpdateHash(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "credentials.json")
store, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if err := store.Register(ctx, "u@example.com", 1, "hash1", "ts"); err != nil {
t.Fatalf("register: %v", err)
}
// Unknown email is a no-op, not an error.
if found, err := store.MarkVerified(ctx, "missing@example.com", "now"); err != nil || found {
t.Fatalf("MarkVerified(missing) = (%v, %v), want (false, nil)", found, err)
}
if found, err := store.MarkVerified(ctx, "U@EXAMPLE.COM", "2026-01-01T00:00:00Z"); err != nil || !found {
t.Fatalf("MarkVerified = (%v, %v), want (true, nil)", found, err)
}
if found, err := store.UpdateHash(ctx, "u@example.com", "hash2"); err != nil || !found {
t.Fatalf("UpdateHash = (%v, %v), want (true, nil)", found, err)
}
// Both changes survive a reload from disk.
reloaded, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
rec, ok := reloaded.Get(ctx, "u@example.com")
if !ok {
t.Fatal("record missing after reload")
}
if rec.Hash != "hash2" {
t.Fatalf("hash = %q, want hash2", rec.Hash)
}
if rec.VerifiedAt == "" {
t.Fatal("verifiedAt not persisted")
}
}
+24
View File
@@ -0,0 +1,24 @@
package customerauth
import "log"
// Notifier delivers transactional auth messages (email verification and
// password reset). The auth server only builds the links; delivery is pluggable
// so this is the seam where a real mailer — or the planned notification service
// (commerce-maturity plan, section C3) — gets wired in. The default LogNotifier
// just logs the link, which is enough for local development.
type Notifier interface {
SendEmailVerification(email, link string)
SendPasswordReset(email, link string)
}
// LogNotifier writes the links to the standard logger instead of sending them.
type LogNotifier struct{}
func (LogNotifier) SendEmailVerification(email, link string) {
log.Printf("customerauth: email verification for %s: %s", email, link)
}
func (LogNotifier) SendPasswordReset(email, link string) {
log.Printf("customerauth: password reset for %s: %s", email, link)
}
+104
View File
@@ -0,0 +1,104 @@
package customerauth
import (
"context"
"sync"
"time"
)
// Limiter throttles repeated failures for a key (login email or "reset:"-prefixed
// email). It is satisfied by the in-memory LoginLimiter (single-instance / dev)
// and by RedisLoginLimiter (shared, horizontally scalable).
type Limiter interface {
// Allowed reports whether an attempt for key may proceed, and when locked the
// remaining lockout duration (for a Retry-After header).
Allowed(ctx context.Context, key string) (bool, time.Duration)
// RecordFailure registers a failed attempt for key.
RecordFailure(ctx context.Context, key string)
// Reset clears the failure history for key (call on a successful auth).
Reset(ctx context.Context, key string)
}
// LoginLimiter is an in-memory failed-attempt limiter that locks a key out after
// too many failures inside a rolling window. It is per-process — matching the
// credential store's single-instance scope; a horizontally-scaled deployment
// would need a shared backing store. Keys are typically the normalized email
// (login) or a "reset:"-prefixed email (reset requests).
type LoginLimiter struct {
mu sync.Mutex
attempts map[string]*attemptState
max int // failures allowed in the window before lockout
window time.Duration // rolling window and lockout duration
}
type attemptState struct {
count int
first time.Time
lockedUntil time.Time
}
// NewLoginLimiter builds a limiter allowing max failures per window before a
// lockout of window duration. Zero/negative values fall back to 5 per 15m.
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
if max <= 0 {
max = 5
}
if window <= 0 {
window = 15 * time.Minute
}
return &LoginLimiter{attempts: make(map[string]*attemptState), max: max, window: window}
}
// Allowed reports whether an attempt for key may proceed. When locked it also
// returns the remaining lockout duration (suitable for a Retry-After header). A
// nil limiter always allows (disabled).
func (l *LoginLimiter) Allowed(_ context.Context, key string) (bool, time.Duration) {
if l == nil {
return true, 0
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.attempts[key]
if st == nil {
return true, 0
}
if now.Before(st.lockedUntil) {
return false, st.lockedUntil.Sub(now)
}
// Window elapsed since the first failure: forget the history.
if now.Sub(st.first) > l.window {
delete(l.attempts, key)
}
return true, 0
}
// RecordFailure registers a failed attempt for key, locking it for window once
// max failures accumulate inside the window.
func (l *LoginLimiter) RecordFailure(_ context.Context, key string) {
if l == nil {
return
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.attempts[key]
if st == nil || now.Sub(st.first) > l.window {
l.attempts[key] = &attemptState{count: 1, first: now}
return
}
st.count++
if st.count >= l.max {
st.lockedUntil = now.Add(l.window)
}
}
// Reset clears the failure history for key. Call it on a successful auth.
func (l *LoginLimiter) Reset(_ context.Context, key string) {
if l == nil {
return
}
l.mu.Lock()
delete(l.attempts, key)
l.mu.Unlock()
}
+215
View File
@@ -0,0 +1,215 @@
package customerauth
import (
"context"
"log"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// Redis-backed implementations of Credentials and Limiter. They let the profile
// service run horizontally: every replica reads/writes the same credential
// records and shares one failed-attempt counter, instead of each holding its own
// file map and in-process counter. Session and verify/reset tokens are already
// stateless HMAC values, so Redis + a shared CUSTOMER_AUTH_SECRET is all that
// the auth surface needs to scale out.
const (
credKeyPrefix = "customerauth:cred:" // hash per normalized email
failKeyPrefix = "customerauth:fail:" // failure counter per key
lockKeyPrefix = "customerauth:lock:" // lockout marker per key
)
// Compile-time guarantees that both backings satisfy the server's interfaces.
var (
_ Credentials = (*CredentialStore)(nil)
_ Credentials = (*RedisCredentialStore)(nil)
_ Limiter = (*LoginLimiter)(nil)
_ Limiter = (*RedisLoginLimiter)(nil)
)
// ---------------------------------------------------------------------------
// RedisCredentialStore
// ---------------------------------------------------------------------------
// RedisCredentialStore stores each credential as a Redis hash at
// "customerauth:cred:<normalized-email>". Register/MarkVerified/UpdateHash are
// atomic via small Lua scripts so concurrent replicas can't race a duplicate
// registration or a lost update.
type RedisCredentialStore struct {
client *redis.Client
luaRegister *redis.Script
luaVerified *redis.Script
luaUpdHash *redis.Script
}
// registerScript creates the hash only if it does not already exist, returning 1
// on create and 0 if the email is taken.
const registerScript = `
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
redis.call('HSET', KEYS[1], 'email', ARGV[1], 'profileId', ARGV[2], 'hash', ARGV[3], 'createdAt', ARGV[4])
return 1`
// markVerifiedScript sets verifiedAt only when missing. Returns -1 if the email
// is unknown, 1 otherwise (whether it set the field now or was already verified).
const markVerifiedScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end
local v = redis.call('HGET', KEYS[1], 'verifiedAt')
if v and v ~= '' then return 1 end
redis.call('HSET', KEYS[1], 'verifiedAt', ARGV[1])
return 1`
// updateHashScript replaces the password hash. Returns 0 if unknown, 1 on update.
const updateHashScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
redis.call('HSET', KEYS[1], 'hash', ARGV[1])
return 1`
// NewRedisCredentialStore builds a Redis-backed credential store and verifies
// connectivity with a ping.
func NewRedisCredentialStore(ctx context.Context, client *redis.Client) (*RedisCredentialStore, error) {
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return &RedisCredentialStore{
client: client,
luaRegister: redis.NewScript(registerScript),
luaVerified: redis.NewScript(markVerifiedScript),
luaUpdHash: redis.NewScript(updateHashScript),
}, nil
}
func (s *RedisCredentialStore) Get(ctx context.Context, email string) (Record, bool) {
key := credKeyPrefix + NormalizeEmail(email)
m, err := s.client.HGetAll(ctx, key).Result()
if err != nil {
log.Printf("customerauth: redis Get(%s): %v", key, err)
return Record{}, false
}
if len(m) == 0 {
return Record{}, false
}
pid, _ := strconv.ParseUint(m["profileId"], 10, 64)
return Record{
Email: m["email"],
ProfileID: pid,
Hash: m["hash"],
CreatedAt: m["createdAt"],
VerifiedAt: m["verifiedAt"],
}, true
}
func (s *RedisCredentialStore) Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error {
norm := NormalizeEmail(email)
created, err := s.luaRegister.Run(ctx, s.client,
[]string{credKeyPrefix + norm},
norm, strconv.FormatUint(profileID, 10), hash, createdAt,
).Int()
if err != nil {
return err
}
if created == 0 {
return ErrEmailExists
}
return nil
}
func (s *RedisCredentialStore) MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error) {
res, err := s.luaVerified.Run(ctx, s.client,
[]string{credKeyPrefix + NormalizeEmail(email)}, verifiedAt,
).Int()
if err != nil {
return false, err
}
return res >= 0, nil // -1 means unknown email
}
func (s *RedisCredentialStore) UpdateHash(ctx context.Context, email, hash string) (bool, error) {
res, err := s.luaUpdHash.Run(ctx, s.client,
[]string{credKeyPrefix + NormalizeEmail(email)}, hash,
).Int()
if err != nil {
return false, err
}
return res == 1, nil
}
func (s *RedisCredentialStore) Delete(ctx context.Context, email string) (bool, error) {
key := credKeyPrefix + NormalizeEmail(email)
res, err := s.client.Del(ctx, key).Result()
if err != nil {
return false, err
}
return res > 0, nil
}
// ---------------------------------------------------------------------------
// RedisLoginLimiter
// ---------------------------------------------------------------------------
// RedisLoginLimiter is the shared-storage counterpart of LoginLimiter: the
// failure counter and lockout marker live in Redis with native TTLs, so the
// window/lockout are enforced across all replicas. It fails open (allows the
// attempt) on a Redis error so an outage degrades to "no rate limiting" rather
// than locking every customer out.
type RedisLoginLimiter struct {
client *redis.Client
luaRecord *redis.Script
max int
window time.Duration
}
// recordFailureScript increments the failure counter (setting the window TTL on
// the first failure) and, once it reaches max, writes a lockout marker with the
// same TTL.
const recordFailureScript = `
local cnt = redis.call('INCR', KEYS[1])
if cnt == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
if cnt >= tonumber(ARGV[1]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[2]) end
return cnt`
// NewRedisLoginLimiter builds a Redis-backed limiter. Zero/negative values fall
// back to 5 failures per 15 minutes (matching the in-memory default).
func NewRedisLoginLimiter(client *redis.Client, max int, window time.Duration) *RedisLoginLimiter {
if max <= 0 {
max = 5
}
if window <= 0 {
window = 15 * time.Minute
}
return &RedisLoginLimiter{
client: client,
luaRecord: redis.NewScript(recordFailureScript),
max: max,
window: window,
}
}
func (l *RedisLoginLimiter) Allowed(ctx context.Context, key string) (bool, time.Duration) {
ttl, err := l.client.PTTL(ctx, lockKeyPrefix+key).Result()
if err != nil {
log.Printf("customerauth: redis limiter Allowed(%s): %v", key, err)
return true, 0 // fail open
}
if ttl > 0 {
return false, ttl
}
return true, 0
}
func (l *RedisLoginLimiter) RecordFailure(ctx context.Context, key string) {
if err := l.luaRecord.Run(ctx, l.client,
[]string{failKeyPrefix + key, lockKeyPrefix + key},
strconv.Itoa(l.max), strconv.Itoa(int(l.window.Seconds())),
).Err(); err != nil {
log.Printf("customerauth: redis limiter RecordFailure(%s): %v", key, err)
}
}
func (l *RedisLoginLimiter) Reset(ctx context.Context, key string) {
if err := l.client.Del(ctx, failKeyPrefix+key, lockKeyPrefix+key).Err(); err != nil {
log.Printf("customerauth: redis limiter Reset(%s): %v", key, err)
}
}
+217 -10
View File
@@ -3,8 +3,11 @@ package customerauth
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/mail" "net/mail"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
@@ -14,9 +17,24 @@ import (
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
) )
// minPasswordLen is the minimum accepted password length at registration. // minPasswordLen is the minimum accepted password length at registration and
// password reset.
const minPasswordLen = 8 const minPasswordLen = 8
// Token lifetimes for the email-verification and password-reset links.
const (
verifyTokenTTL = 24 * time.Hour
resetTokenTTL = 1 * time.Hour
)
// Storefront paths (appended to Options.BaseURL) that the verification and reset
// links point at. The page reads the token from the query string and POSTs it
// back to /verify or /reset.
const (
verifyLinkPath = "/account/verify"
resetLinkPath = "/account/reset"
)
// ProfileApplier is the subset of the profile grain pool the auth server needs. // ProfileApplier is the subset of the profile grain pool the auth server needs.
// The pool (and the UCP adapter's ProfileApplier) already satisfies it. // The pool (and the UCP adapter's ProfileApplier) already satisfies it.
type ProfileApplier interface { type ProfileApplier interface {
@@ -24,21 +42,60 @@ type ProfileApplier interface {
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
} }
// Server exposes password signup/login and identity-linking over HTTP, backed // Server exposes password signup/login, email verification, password reset and
// by the credential store (email→id+hash) and the profile grain pool. // identity-linking over HTTP, backed by the credential store (email→id+hash) and
// the profile grain pool.
type Server struct { type Server struct {
store *CredentialStore store Credentials
applier ProfileApplier applier ProfileApplier
signer *Signer signer *Signer
ttl time.Duration ttl time.Duration
limiter Limiter
notifier Notifier
baseURL string
requireVerified bool
}
// Options configures optional auth-server behavior. The zero value is valid: an
// in-memory login limiter (5 failures / 15m), a logging notifier, and no
// verified-email requirement for login.
type Options struct {
// Limiter throttles failed logins and reset requests. Nil installs a default
// in-memory limiter (5 failures / 15m). Inject a RedisLoginLimiter for a
// horizontally-scaled deployment.
Limiter Limiter
// Notifier delivers verification and reset links. Nil installs LogNotifier.
Notifier Notifier
// BaseURL is the public origin used to build links in messages, e.g.
// "https://shop.tornberg.me". The verify/reset paths are appended to it.
BaseURL string
// RequireVerifiedEmail, when true, blocks login until the email is verified.
// Off by default so pre-existing accounts (which carry no verification
// record) keep working.
RequireVerifiedEmail bool
} }
// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL. // NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL.
func NewServer(store *CredentialStore, applier ProfileApplier, signer *Signer, ttl time.Duration) *Server { func NewServer(store Credentials, applier ProfileApplier, signer *Signer, ttl time.Duration, opts Options) *Server {
if ttl <= 0 { if ttl <= 0 {
ttl = DefaultSessionTTL ttl = DefaultSessionTTL
} }
return &Server{store: store, applier: applier, signer: signer, ttl: ttl} if opts.Limiter == nil {
opts.Limiter = NewLoginLimiter(0, 0)
}
if opts.Notifier == nil {
opts.Notifier = LogNotifier{}
}
return &Server{
store: store,
applier: applier,
signer: signer,
ttl: ttl,
limiter: opts.Limiter,
notifier: opts.Notifier,
baseURL: strings.TrimRight(opts.BaseURL, "/"),
requireVerified: opts.RequireVerifiedEmail,
}
} }
// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth // Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth
@@ -49,6 +106,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /me", s.handleMe) mux.HandleFunc("GET /me", s.handleMe)
mux.HandleFunc("POST /verify-request", s.handleVerifyRequest)
mux.HandleFunc("POST /verify", s.handleVerify)
mux.HandleFunc("POST /reset-request", s.handleResetRequest)
mux.HandleFunc("POST /reset", s.handleResetComplete)
mux.HandleFunc("POST /link-cart", s.handleLinkCart) mux.HandleFunc("POST /link-cart", s.handleLinkCart)
mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout) mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout)
mux.HandleFunc("POST /link-order", s.handleLinkOrder) mux.HandleFunc("POST /link-order", s.handleLinkOrder)
@@ -86,6 +147,22 @@ type linkOrderRequest struct {
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
} }
// tokenRequest carries a verification token.
type tokenRequest struct {
Token string `json:"token"`
}
// resetRequest asks for a password-reset link to be sent to an email.
type resetRequest struct {
Email string `json:"email"`
}
// resetCompleteRequest carries a reset token and the new password.
type resetCompleteRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an // CustomerResponse mirrors the UCP customer projection (kept local to avoid an
// import cycle with internal/ucp). The password hash is never included. // import cycle with internal/ucp). The password hash is never included.
type CustomerResponse struct { type CustomerResponse struct {
@@ -98,6 +175,9 @@ type CustomerResponse struct {
AvatarURL string `json:"avatarUrl,omitempty"` AvatarURL string `json:"avatarUrl,omitempty"`
Addresses []AddressResponse `json:"addresses"` Addresses []AddressResponse `json:"addresses"`
Orders []OrderRef `json:"orders"` Orders []OrderRef `json:"orders"`
// EmailVerified reflects whether the email-verification flow has completed.
EmailVerified bool `json:"emailVerified"`
} }
type AddressResponse struct { type AddressResponse struct {
@@ -137,7 +217,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters") writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
return return
} }
if _, exists := s.store.Get(email); exists { if _, exists := s.store.Get(r.Context(), email); exists {
writeErr(w, http.StatusConflict, "an account with this email already exists") writeErr(w, http.StatusConflict, "an account with this email already exists")
return return
} }
@@ -169,7 +249,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
// Persist the credential only after the profile is created. If this fails // Persist the credential only after the profile is created. If this fails
// the profile exists but is unreachable by login — acceptable and rare. // the profile exists but is unreachable by login — acceptable and rare.
if err := s.store.Register(email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil { if err := s.store.Register(r.Context(), email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
if err == ErrEmailExists { if err == ErrEmailExists {
writeErr(w, http.StatusConflict, "an account with this email already exists") writeErr(w, http.StatusConflict, "an account with this email already exists")
return return
@@ -178,6 +258,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
// Send the verification link (best-effort, via the configured notifier).
s.sendVerification(email)
s.issueSession(w, r, id) s.issueSession(w, r, id)
s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id) s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id)
} }
@@ -188,14 +271,26 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "invalid request body") writeErr(w, http.StatusBadRequest, "invalid request body")
return return
} }
rec, ok := s.store.Get(req.Email) key := NormalizeEmail(req.Email)
if ok, retry := s.limiter.Allowed(r.Context(), key); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return
}
rec, ok := s.store.Get(r.Context(), req.Email)
// Always run a verify (even on miss, against the stored hash if present) and // Always run a verify (even on miss, against the stored hash if present) and
// return a single generic error so the response does not reveal whether the // return a single generic error so the response does not reveal whether the
// email exists. // email exists.
if !ok || !VerifyPassword(req.Password, rec.Hash) { if !ok || !VerifyPassword(req.Password, rec.Hash) {
s.limiter.RecordFailure(r.Context(), key)
writeErr(w, http.StatusUnauthorized, "invalid email or password") writeErr(w, http.StatusUnauthorized, "invalid email or password")
return return
} }
if s.requireVerified && rec.VerifiedAt == "" {
writeErr(w, http.StatusForbidden, "email not verified")
return
}
s.limiter.Reset(r.Context(), key)
s.issueSession(w, r, rec.ProfileID) s.issueSession(w, r, rec.ProfileID)
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID) s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID)
} }
@@ -213,6 +308,101 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id) s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
} }
// handleVerifyRequest re-sends the email-verification link for the logged-in
// customer. Requires a session so it can't be used to enumerate addresses.
func (s *Server) handleVerifyRequest(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil || g.Email == "" {
writeErr(w, http.StatusInternalServerError, "could not read profile")
return
}
s.sendVerification(NormalizeEmail(g.Email))
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
}
// handleVerify consumes a verification token and marks the email verified. An
// unknown subject still returns success so the endpoint reveals nothing.
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
var req tokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
email, err := s.signer.ParsePurpose(purposeVerifyEmail, req.Token)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid or expired token")
return
}
if _, err := s.store.MarkVerified(r.Context(), email, time.Now().UTC().Format(time.RFC3339)); err != nil {
writeErr(w, http.StatusInternalServerError, "could not record verification")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
}
// handleResetRequest issues a password-reset link for a registered email. It
// always returns 200 (whether or not the email exists) so it never reveals
// account existence, and is rate-limited per email to prevent mail-bombing.
func (s *Server) handleResetRequest(w http.ResponseWriter, r *http.Request) {
var req resetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
email := NormalizeEmail(req.Email)
limiterKey := "reset:" + email
if ok, retry := s.limiter.Allowed(r.Context(), limiterKey); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
writeErr(w, http.StatusTooManyRequests, "too many requests, try again later")
return
}
if _, exists := s.store.Get(r.Context(), email); exists {
token := s.signer.IssuePurpose(purposePasswordReset, email, resetTokenTTL)
s.notifier.SendPasswordReset(email, s.link(resetLinkPath, token))
}
s.limiter.RecordFailure(r.Context(), limiterKey)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleResetComplete consumes a reset token and replaces the password hash.
func (s *Server) handleResetComplete(w http.ResponseWriter, r *http.Request) {
var req resetCompleteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.Password) < minPasswordLen {
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
email, err := s.signer.ParsePurpose(purposePasswordReset, req.Token)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid or expired token")
return
}
hash, err := HashPassword(req.Password)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not hash password")
return
}
found, err := s.store.UpdateHash(r.Context(), email, hash)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not update password")
return
}
if !found {
// Token signed for an email no longer present.
writeErr(w, http.StatusBadRequest, "invalid or expired token")
return
}
s.limiter.Reset(r.Context(), NormalizeEmail(email))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r) id, ok := s.requireSession(w, r)
if !ok { if !ok {
@@ -292,6 +482,19 @@ func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, id uint64)
SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl) SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl)
} }
// sendVerification mints a verification token for email and hands the link to
// the notifier. email must already be normalized.
func (s *Server) sendVerification(email string) {
token := s.signer.IssuePurpose(purposeVerifyEmail, email, verifyTokenTTL)
s.notifier.SendEmailVerification(email, s.link(verifyLinkPath, token))
}
// link builds an absolute link to a storefront path carrying the token as a
// query parameter. With no configured BaseURL it returns a relative link.
func (s *Server) link(path, token string) string {
return fmt.Sprintf("%s%s?token=%s", s.baseURL, path, url.QueryEscape(token))
}
// requireSession reads and validates the session cookie, writing 401 on // requireSession reads and validates the session cookie, writing 401 on
// failure. It returns the profile id and whether a valid session was present. // failure. It returns the profile id and whether a valid session was present.
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) { func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) {
@@ -315,7 +518,11 @@ func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status in
writeErr(w, http.StatusInternalServerError, "could not read profile") writeErr(w, http.StatusInternalServerError, "could not read profile")
return return
} }
writeJSON(w, status, grainToCustomer(idStr, g)) resp := grainToCustomer(idStr, g)
if rec, ok := s.store.Get(r.Context(), g.Email); ok {
resp.EmailVerified = rec.VerifiedAt != ""
}
writeJSON(w, status, resp)
} }
func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse { func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
+75 -3
View File
@@ -1,6 +1,7 @@
package customerauth package customerauth
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -14,6 +15,27 @@ import (
// associated with a credential record. // associated with a credential record.
var ErrEmailExists = errors.New("customerauth: email already registered") var ErrEmailExists = errors.New("customerauth: email already registered")
// Credentials is the email→credential index the auth server depends on. It is
// satisfied by the file-backed CredentialStore (single-instance / dev) and by
// RedisCredentialStore (shared, horizontally scalable). All methods normalize
// the email key internally.
type Credentials interface {
// Get returns the record for email and whether it exists. A backing-store
// failure is reported as "not found" so callers fail closed.
Get(ctx context.Context, email string) (Record, bool)
// Register adds a new credential, returning ErrEmailExists if taken.
Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error
// MarkVerified records email verification. The bool reports whether the
// email was known; an unknown email is not an error (avoids enumeration).
MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error)
// UpdateHash replaces the password hash. The bool reports whether the email
// was known; an unknown email is not an error.
UpdateHash(ctx context.Context, email, hash string) (bool, error)
// Delete removes a credential record by email. The bool reports whether the email
// was known.
Delete(ctx context.Context, email string) (bool, error)
}
// Record is a single stored credential: which profile an email belongs to and // Record is a single stored credential: which profile an email belongs to and
// its password hash. It deliberately does not embed any profile data — the // its password hash. It deliberately does not embed any profile data — the
// profile grain remains the source of truth for that. // profile grain remains the source of truth for that.
@@ -22,6 +44,7 @@ type Record struct {
ProfileID uint64 `json:"profileId"` ProfileID uint64 `json:"profileId"`
Hash string `json:"hash"` Hash string `json:"hash"`
CreatedAt string `json:"createdAt,omitempty"` CreatedAt string `json:"createdAt,omitempty"`
VerifiedAt string `json:"verifiedAt,omitempty"`
} }
// CredentialStore is a small email→credential index persisted to a JSON file. // CredentialStore is a small email→credential index persisted to a JSON file.
@@ -61,8 +84,9 @@ func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email)) return strings.ToLower(strings.TrimSpace(email))
} }
// Get returns the record for email and whether it exists. // Get returns the record for email and whether it exists. The ctx is accepted
func (s *CredentialStore) Get(email string) (Record, bool) { // for interface parity with the Redis store; the file store ignores it.
func (s *CredentialStore) Get(_ context.Context, email string) (Record, bool) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
r, ok := s.byMail[NormalizeEmail(email)] r, ok := s.byMail[NormalizeEmail(email)]
@@ -71,7 +95,7 @@ func (s *CredentialStore) Get(email string) (Record, bool) {
// Register adds a new credential record and persists the store. It returns // Register adds a new credential record and persists the store. It returns
// ErrEmailExists if the email is already taken. // ErrEmailExists if the email is already taken.
func (s *CredentialStore) Register(email string, profileID uint64, hash, createdAt string) error { func (s *CredentialStore) Register(_ context.Context, email string, profileID uint64, hash, createdAt string) error {
key := NormalizeEmail(email) key := NormalizeEmail(email)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -82,6 +106,54 @@ func (s *CredentialStore) Register(email string, profileID uint64, hash, created
return s.persistLocked() return s.persistLocked()
} }
// MarkVerified records that email completed email verification and persists the
// store. It is a no-op if already verified. The bool reports whether the email
// was known; an unknown email is not an error (callers avoid leaking which
// emails exist).
func (s *CredentialStore) MarkVerified(_ context.Context, email, verifiedAt string) (bool, error) {
key := NormalizeEmail(email)
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.byMail[key]
if !ok {
return false, nil
}
if rec.VerifiedAt != "" {
return true, nil
}
rec.VerifiedAt = verifiedAt
s.byMail[key] = rec
return true, s.persistLocked()
}
// UpdateHash replaces the stored password hash for email and persists the store.
// The bool reports whether the email was known; an unknown email is not an error.
func (s *CredentialStore) UpdateHash(_ context.Context, email, hash string) (bool, error) {
key := NormalizeEmail(email)
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.byMail[key]
if !ok {
return false, nil
}
rec.Hash = hash
s.byMail[key] = rec
return true, s.persistLocked()
}
// Delete removes the credential record for email and persists the store.
// The bool reports whether the email was known.
func (s *CredentialStore) Delete(_ context.Context, email string) (bool, error) {
key := NormalizeEmail(email)
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.byMail[key]; !ok {
return false, nil
}
delete(s.byMail, key)
return true, s.persistLocked()
}
// persistLocked writes the whole store to disk atomically. Caller holds s.mu. // persistLocked writes the whole store to disk atomically. Caller holds s.mu.
func (s *CredentialStore) persistLocked() error { func (s *CredentialStore) persistLocked() error {
records := make([]Record, 0, len(s.byMail)) records := make([]Record, 0, len(s.byMail))
+62
View File
@@ -0,0 +1,62 @@
package customerauth
import (
"crypto/hmac"
"encoding/base64"
"strconv"
"strings"
"time"
)
// Purpose-scoped tokens (email verification, password reset) reuse the session
// Signer's HMAC secret but bind a purpose + subject, so a token minted for one
// flow cannot be replayed in another. Format:
//
// base64url(<purpose> NUL <subject> NUL <expUnix>) "." base64url(hmac)
//
// subject is the normalized email. Like session tokens these are signed, not
// encrypted: they carry no secret, only a purpose, an email and an expiry, and
// the HMAC makes them tamper-evident.
const (
purposeVerifyEmail = "verify"
purposePasswordReset = "reset"
)
// IssuePurpose returns a signed, purpose-bound token for subject that expires
// after ttl.
func (s *Signer) IssuePurpose(purpose, subject string, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
payload := purpose + "\x00" + subject + "\x00" + strconv.FormatInt(exp, 10)
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
return b + "." + s.sign(b)
}
// ParsePurpose verifies a purpose token and returns its subject. It requires the
// embedded purpose to match want and the token to be unexpired, returning
// ErrInvalidToken for a bad signature/format/purpose and ErrExpiredToken when
// the token has expired.
func (s *Signer) ParsePurpose(want, token string) (string, error) {
b, sig, ok := strings.Cut(token, ".")
if !ok || b == "" || sig == "" {
return "", ErrInvalidToken
}
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
return "", ErrInvalidToken
}
raw, err := base64.RawURLEncoding.DecodeString(b)
if err != nil {
return "", ErrInvalidToken
}
parts := strings.Split(string(raw), "\x00")
if len(parts) != 3 || parts[0] != want {
return "", ErrInvalidToken
}
exp, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return "", ErrInvalidToken
}
if time.Now().Unix() >= exp {
return "", ErrExpiredToken
}
return parts[1], nil
}
+3 -2
View File
@@ -554,7 +554,8 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
Name: name, Name: name,
Quantity: int32(it.Quantity), Quantity: int32(it.Quantity),
UnitPrice: it.Price.IncVat, UnitPrice: it.Price.IncVat,
TaxRate: int32(it.Tax), // CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
TaxRate: int32(it.Tax / 100),
}) })
} }
for _, d := range g.Deliveries { for _, d := range g.Deliveries {
@@ -567,7 +568,7 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
Name: "Delivery", Name: "Delivery",
Quantity: 1, Quantity: 1,
UnitPrice: d.Price.IncVat, UnitPrice: d.Price.IncVat,
TaxRate: 2500, TaxRate: 25,
}) })
} }
return lines return lines
+62 -2
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/profile" "git.k6n.net/mats/go-cart-actor/pkg/profile"
@@ -16,11 +17,17 @@ import (
// CustomerServer is the UCP REST adapter over the profile grain pool. // CustomerServer is the UCP REST adapter over the profile grain pool.
type CustomerServer struct { type CustomerServer struct {
applier ProfileApplier applier ProfileApplier
deleter CredentialDeleter
auditLogPath string
} }
// NewCustomerServer builds a UCP REST adapter over a profile grain pool. // NewCustomerServer builds a UCP REST adapter over a profile grain pool.
func NewCustomerServer(applier ProfileApplier) *CustomerServer { func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) *CustomerServer {
return &CustomerServer{applier: applier} var del CredentialDeleter
if len(deleter) > 0 {
del = deleter[0]
}
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -276,6 +283,59 @@ func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g)) writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
} }
// handleDeleteCustomer handles DELETE /customers/{id}.
func (s *CustomerServer) handleDeleteCustomer(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
// 1. Fetch current profile grain to get the email address.
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
email := g.Email
// 2. Apply the AnonymizeProfile mutation to clear all PII.
msg := &messages.AnonymizeProfile{}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to anonymize customer: "+err.Error())
return
}
// 3. Delete the credentials from the auth store if we have a deleter.
if email != "" && s.deleter != nil {
if _, err := s.deleter.Delete(r.Context(), email); err != nil {
fmt.Printf("ucp: failed to delete credentials for email %s: %v\n", email, err)
}
}
// 4. Log audit trail to file (GDPR requirement, append-only, readable, no PII)
if s.auditLogPath != "" {
logLine := fmt.Sprintf("[%s] ACTION=GDPR_ERASURE PROFILE_ID=%s STATUS=SUCCESS\n",
time.Now().UTC().Format(time.RFC3339), r.PathValue("id"))
// Import "os" package is handled or we can open it directly.
// Since we need to write to the file, let's open it in append-only mode.
// To ensure directory exists, we write to the file.
if f, err := os.OpenFile(s.auditLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
_, _ = f.WriteString(logLine)
_ = f.Close()
} else {
fmt.Printf("ucp: failed to open audit log file %s: %v\n", s.auditLogPath, err)
}
}
// Log audit trail to stdout (GDPR requirement)
fmt.Printf("AUDIT: GDPR right to erasure executed for customer profile id %d\n", id)
w.WriteHeader(http.StatusNoContent)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Identity linking helpers // Identity linking helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+87 -10
View File
@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -105,6 +107,12 @@ func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto
g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId}) g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId})
case *messages.LinkOrder: case *messages.LinkOrder:
g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status}) g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status})
case *messages.AnonymizeProfile:
g.Name = ""
g.Email = ""
g.Phone = ""
g.AvatarUrl = ""
g.Addresses = nil
} }
} }
return &actor.MutationResult[profile.ProfileGrain]{ return &actor.MutationResult[profile.ProfileGrain]{
@@ -138,7 +146,7 @@ func testProfileID(t *testing.T, applier *testProfileApplier) string {
func TestGetCustomer(t *testing.T) { func TestGetCustomer(t *testing.T) {
applier := newTestProfileApplier() applier := newTestProfileApplier()
id := testProfileID(t, applier) id := testProfileID(t, applier)
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
// Set up some profile data // Set up some profile data
pid, _ := profile.ParseProfileId(id) pid, _ := profile.ParseProfileId(id)
@@ -173,7 +181,7 @@ func TestGetCustomer(t *testing.T) {
func TestGetCustomerNotFound(t *testing.T) { func TestGetCustomerNotFound(t *testing.T) {
applier := newTestProfileApplier() applier := newTestProfileApplier()
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet. // "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
req := httptest.NewRequest("GET", "/!invalid!", nil) req := httptest.NewRequest("GET", "/!invalid!", nil)
@@ -188,7 +196,7 @@ func TestGetCustomerNotFound(t *testing.T) {
func TestUpdateCustomer(t *testing.T) { func TestUpdateCustomer(t *testing.T) {
applier := newTestProfileApplier() applier := newTestProfileApplier()
id := testProfileID(t, applier) id := testProfileID(t, applier)
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}` body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}`
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body)) req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body))
@@ -224,7 +232,7 @@ func TestUpdateCustomer(t *testing.T) {
func TestAddAddress(t *testing.T) { func TestAddAddress(t *testing.T) {
applier := newTestProfileApplier() applier := newTestProfileApplier()
id := testProfileID(t, applier) id := testProfileID(t, applier)
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
body := `{ body := `{
"label": "Home", "label": "Home",
@@ -266,7 +274,7 @@ func TestAddAddress(t *testing.T) {
func TestAddAddressMissingRequired(t *testing.T) { func TestAddAddressMissingRequired(t *testing.T) {
applier := newTestProfileApplier() applier := newTestProfileApplier()
id := testProfileID(t, applier) id := testProfileID(t, applier)
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
// Missing addressLine1 // Missing addressLine1
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}` body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
@@ -283,7 +291,7 @@ func TestAddAddressMissingRequired(t *testing.T) {
func TestUpdateAddress(t *testing.T) { func TestUpdateAddress(t *testing.T) {
applier := newTestProfileApplier() applier := newTestProfileApplier()
id := testProfileID(t, applier) id := testProfileID(t, applier)
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
// First add an address // First add an address
pid, _ := profile.ParseProfileId(id) pid, _ := profile.ParseProfileId(id)
@@ -337,7 +345,7 @@ func TestUpdateAddress(t *testing.T) {
func TestUpdateAddressInvalidID(t *testing.T) { func TestUpdateAddressInvalidID(t *testing.T) {
applier := newTestProfileApplier() applier := newTestProfileApplier()
id := testProfileID(t, applier) id := testProfileID(t, applier)
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
body := `{"label": "New Home"}` body := `{"label": "New Home"}`
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body)) req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
@@ -353,7 +361,7 @@ func TestUpdateAddressInvalidID(t *testing.T) {
func TestRemoveAddress(t *testing.T) { func TestRemoveAddress(t *testing.T) {
applier := newTestProfileApplier() applier := newTestProfileApplier()
id := testProfileID(t, applier) id := testProfileID(t, applier)
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
// First add an address // First add an address
pid, _ := profile.ParseProfileId(id) pid, _ := profile.ParseProfileId(id)
@@ -398,7 +406,7 @@ func TestRemoveAddressNotFound(t *testing.T) {
applier.Get(context.Background(), uint64(pid)) applier.Get(context.Background(), uint64(pid))
id := pid.String() id := pid.String()
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil) req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
@@ -444,7 +452,7 @@ func TestRemoveAddressNotFoundMutation(t *testing.T) {
func TestCustomerHandlerInvalidID(t *testing.T) { func TestCustomerHandlerInvalidID(t *testing.T) {
applier := newTestProfileApplier() applier := newTestProfileApplier()
handler := CustomerHandler(applier) handler := CustomerHandler(applier, "")
// Path "/" now has POST / registered (create customer), so GET / is 405. // Path "/" now has POST / registered (create customer), so GET / is 405.
// Paths with invalid base62 characters should get 400. // Paths with invalid base62 characters should get 400.
@@ -496,3 +504,72 @@ func TestCustomerHandlerThroughCombinedMount(t *testing.T) {
t.Fatalf("expected id %q, got %q", id, resp.Id) t.Fatalf("expected id %q, got %q", id, resp.Id)
} }
} }
type testCredentialDeleter struct {
deletedEmail string
}
func (d *testCredentialDeleter) Delete(_ context.Context, email string) (bool, error) {
d.deletedEmail = email
return true, nil
}
func TestDeleteCustomer(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
deleter := &testCredentialDeleter{}
auditPath := filepath.Join(t.TempDir(), "audit.log")
handler := CustomerHandler(applier, auditPath, deleter)
// Set up some profile data
pid, _ := profile.ParseProfileId(id)
applier.Apply(context.Background(), uint64(pid), &messages.SetProfile{
Name: proto.String("Alice"),
Email: proto.String("alice@example.com"),
})
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
Address: &messages.Address{
Label: "Home",
AddressLine1: "Storgatan 1",
City: "Stockholm",
Zip: "111 22",
Country: "SE",
},
})
// DELETE /customers/{id}
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s", id), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204 No Content, got %d. Body: %s", rec.Code, rec.Body.String())
}
// Verify credentials deleted
if deleter.deletedEmail != "alice@example.com" {
t.Fatalf("expected deleted email 'alice@example.com', got %q", deleter.deletedEmail)
}
// Verify profile is anonymized
g, _ := applier.Get(context.Background(), uint64(pid))
if g.Name != "" || g.Email != "" || g.Addresses != nil {
t.Fatalf("expected profile to be anonymized, got Name=%q, Email=%q, Addresses=%v", g.Name, g.Email, g.Addresses)
}
// Verify audit log has the correct entry and no PII
logBytes, err := os.ReadFile(auditPath)
if err != nil {
t.Fatalf("failed to read audit log: %v", err)
}
logContent := string(logBytes)
if !strings.Contains(logContent, "ACTION=GDPR_ERASURE") {
t.Fatal("expected audit log to record erasure action")
}
if !strings.Contains(logContent, "PROFILE_ID="+id) {
t.Fatal("expected audit log to record profile ID")
}
if strings.Contains(logContent, "Alice") || strings.Contains(logContent, "alice@example.com") {
t.Fatal("expected audit log to exclude PII")
}
}
+7 -3
View File
@@ -86,12 +86,13 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning: // To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
// //
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer)) // mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
func CustomerHandler(applier ProfileApplier) http.Handler { func CustomerHandler(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) http.Handler {
s := NewCustomerServer(applier) s := NewCustomerServer(applier, auditLogPath, deleter...)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /", s.handleCreateCustomer) mux.HandleFunc("POST /", s.handleCreateCustomer)
mux.HandleFunc("GET /{id}", s.handleGetCustomer) mux.HandleFunc("GET /{id}", s.handleGetCustomer)
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer) mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer)
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress) mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress) mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress)
mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress) mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress)
@@ -107,6 +108,8 @@ type Options struct {
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
OrderApplier OrderApplier // optional order creation for complete endpoint OrderApplier OrderApplier // optional order creation for complete endpoint
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
CredentialDeleter CredentialDeleter // optional, for deleting login credentials during GDPR erasure
ProfileAuditLog string // file path for GDPR right-to-erasure audit log
} }
// Handler returns an http.Handler that serves all mounted UCP endpoints under // Handler returns an http.Handler that serves all mounted UCP endpoints under
@@ -148,10 +151,11 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
// Customer endpoints (optional) // Customer endpoints (optional)
if opts.ProfileApplier != nil { if opts.ProfileApplier != nil {
customerSrv := NewCustomerServer(opts.ProfileApplier) customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer) mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer) mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer) mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer)
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress) mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress) mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress)
mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress) mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress)
+5
View File
@@ -56,6 +56,11 @@ type ProfileApplier interface {
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
} }
// CredentialDeleter represents the subset of the credential store needed to delete credentials.
type CredentialDeleter interface {
Delete(ctx context.Context, email string) (bool, error)
}
// OrderApplier is the interface the UCP checkout adapter uses to create a real // OrderApplier is the interface the UCP checkout adapter uses to create a real
// order from a completed checkout session. It abstracts over the transport // order from a completed checkout session. It abstracts over the transport
// (direct grain pool, HTTP, etc.) so the adapter is portable. // (direct grain pool, HTTP, etc.) so the adapter is portable.
-7
View File
@@ -9,7 +9,6 @@ import (
"time" "time"
messages "git.k6n.net/mats/go-cart-actor/proto/control" messages "git.k6n.net/mats/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric"
@@ -31,7 +30,6 @@ const name = "grpc_server"
var ( var (
tracer = otel.Tracer(name) tracer = otel.Tracer(name)
meter = otel.Meter(name) meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
pingCalls metric.Int64Counter pingCalls metric.Int64Counter
negotiateCalls metric.Int64Counter negotiateCalls metric.Int64Counter
getLocalActorIdsCalls metric.Int64Counter getLocalActorIdsCalls metric.Int64Counter
@@ -88,7 +86,6 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
attribute.String("host", req.Host), attribute.String("host", req.Host),
attribute.Int("id_count", len(req.Ids)), attribute.Int("id_count", len(req.Ids)),
) )
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host))) announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
err := s.pool.HandleOwnershipChange(req.Host, req.Ids) err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
@@ -139,7 +136,6 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
attribute.String("host", req.Host), attribute.String("host", req.Host),
attribute.Int("id_count", len(req.Ids)), attribute.Int("id_count", len(req.Ids)),
) )
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host))) announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids) err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
@@ -215,7 +211,6 @@ func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.Negotiat
attribute.String("component", "controlplane"), attribute.String("component", "controlplane"),
attribute.Int("known_hosts_count", len(req.KnownHosts)), attribute.Int("known_hosts_count", len(req.KnownHosts)),
) )
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
negotiateCalls.Add(ctx, 1) negotiateCalls.Add(ctx, 1)
s.pool.Negotiate(req.KnownHosts) s.pool.Negotiate(req.KnownHosts)
@@ -231,7 +226,6 @@ func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.E
attribute.String("component", "controlplane"), attribute.String("component", "controlplane"),
attribute.Int("id_count", len(ids)), attribute.Int("id_count", len(ids)),
) )
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
getLocalActorIdsCalls.Add(ctx, 1) getLocalActorIdsCalls.Add(ctx, 1)
return &messages.ActorIdsReply{Ids: ids}, nil return &messages.ActorIdsReply{Ids: ids}, nil
@@ -246,7 +240,6 @@ func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNot
attribute.String("component", "controlplane"), attribute.String("component", "controlplane"),
attribute.String("host", host), attribute.String("host", host),
) )
logger.InfoContext(ctx, "closing notice", "host", host)
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host))) closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
if host != "" { if host != "" {
+7
View File
@@ -54,6 +54,8 @@ type CartItem struct {
OrderReference string `json:"orderReference,omitempty"` OrderReference string `json:"orderReference,omitempty"`
IsSubscribed bool `json:"isSubscribed,omitempty"` IsSubscribed bool `json:"isSubscribed,omitempty"`
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"` ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
InventoryTracked bool `json:"inventoryTracked,omitempty"`
DropShip bool `json:"dropShip,omitempty"`
// Extra holds arbitrary dynamic product data. Its keys are flattened onto // Extra holds arbitrary dynamic product data. Its keys are flattened onto
// the item object in JSON (see cart_item_json.go), so they are returned // the item object in JSON (see cart_item_json.go), so they are returned
@@ -158,6 +160,7 @@ type Voucher struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Id uint32 `json:"id"` Id uint32 `json:"id"`
Value int64 `json:"value"` Value int64 `json:"value"`
BypassedByPromotions bool `json:"-"`
} }
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) { func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
@@ -298,6 +301,10 @@ func (c *CartGrain) UpdateTotals() {
_, ok := voucher.AppliesTo(c) _, ok := voucher.AppliesTo(c)
voucher.Applied = false voucher.Applied = false
if ok { if ok {
if voucher.BypassedByPromotions {
voucher.Applied = true
continue
}
value := NewPriceFromIncVat(voucher.Value, 25) value := NewPriceFromIncVat(voucher.Value, 25)
if c.TotalPrice.IncVat <= value.IncVat { if c.TotalPrice.IncVat <= value.IncVat {
// don't apply discounts to more than the total price // don't apply discounts to more than the total price
+6
View File
@@ -51,6 +51,12 @@ func (c *CartMutationContext) UseReservations(item *CartItem) bool {
if item.ReservationEndTime != nil { if item.ReservationEndTime != nil {
return true return true
} }
if item.DropShip {
return false
}
if item.InventoryTracked {
return true
}
return item.Cgm == "55010" return item.Cgm == "55010"
} }
+4
View File
@@ -76,6 +76,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
} }
existing.Quantity += uint16(m.Quantity) existing.Quantity += uint16(m.Quantity)
existing.Stock = uint16(m.Stock) existing.Stock = uint16(m.Stock)
existing.InventoryTracked = m.InventoryTracked
existing.DropShip = m.DropShip
// If existing had nil store but new has one, adopt it. // If existing had nil store but new has one, adopt it.
if existing.StoreId == nil && m.StoreId != nil { if existing.StoreId == nil && m.StoreId != nil {
existing.StoreId = m.StoreId existing.StoreId = m.StoreId
@@ -144,6 +146,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
Extra: decodeExtra(m.ExtraJson), Extra: decodeExtra(m.ExtraJson),
CustomFields: m.CustomFields, CustomFields: m.CustomFields,
InventoryTracked: m.InventoryTracked,
DropShip: m.DropShip,
} }
if needsReservation && c.UseReservations(cartItem) { if needsReservation && c.UseReservations(cartItem) {
+1 -1
View File
@@ -50,7 +50,7 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
if m.Quantity <= 0 { if m.Quantity <= 0 {
// Remove the item // Remove the item
itemToRemove := g.Items[foundIndex] itemToRemove := g.Items[foundIndex]
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.Before(time.Now()) { if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.After(time.Now()) {
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId) err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
if err != nil { if err != nil {
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId) log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
+58
View File
@@ -0,0 +1,58 @@
package cart
// TaxProvider computes taxes for orders and line items.
// It mirrors the PaymentProvider and ShippingProvider seam patterns: one
// interface, interchangeable implementations.
//
// All tax rates are expressed as raw percent (e.g. 25 = 25 %). This matches the
// OrderLine.tax_rate proto convention.
type TaxProvider interface {
// Name returns the provider name for identification (logging, metrics).
Name() string
// ComputeTax computes the tax portion from a gross (inc-VAT) total and tax
// rate. Both total and return value are in minor currency units (ore).
// taxRate is expressed as raw percent (e.g. 25 = 25 %).
//
// Formula: tax = total x rate / (100 + rate)
//
// When taxRate <= 0 the result is 0 (untaxed / zero-rated items).
ComputeTax(total int64, taxRate int) int64
// DefaultTaxRate returns the default VAT rate for a country, expressed as
// raw percent (e.g. 25 = 25 %). Used for deliveries and items where no
// explicit rate was recorded at add-to-cart time.
//
// Return 0 if the country is unknown or untaxed.
DefaultTaxRate(country string) int
}
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
// taxRate is raw percent (e.g. 25 = 25 %). When taxRate <= 0 the result is 0.
//
// This differs from GetTaxAmount which uses the CartItem convention
// (percent x 100, e.g. 2500 = 25.00 %) with formula total x rate / (10000 + rate).
func ComputeTax(total int64, taxRate int) int64 {
if taxRate <= 0 {
return 0
}
return total * int64(taxRate) / int64(100+taxRate)
}
// StaticTaxProvider is a stateless, always-available TaxProvider that simply
// runs the formula without any country-specific rates. Use for local dev,
// tests, and as a no-dependency default.
type StaticTaxProvider struct{}
var _ TaxProvider = (*StaticTaxProvider)(nil)
func NewStaticTaxProvider() *StaticTaxProvider { return &StaticTaxProvider{} }
func (p *StaticTaxProvider) Name() string { return "static" }
func (p *StaticTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// DefaultTaxRate returns 0 for any country (no awareness).
func (p *StaticTaxProvider) DefaultTaxRate(_ string) int { return 0 }
+55
View File
@@ -0,0 +1,55 @@
package cart
// NordicTaxProvider implements TaxProvider for the Nordic countries
// (SE, NO, DK, FI). It encodes the standard statutory VAT rates:
//
// SE: 25 % (standard), 12 % (food, hotels), 6 % (books, transport)
// NO: 25 % (standard), 15 % (food), 12 %
// DK: 25 % (standard)
// FI: 25 % (standard; actual rate is 25.5 % as of 2026)
//
// All rates are raw percent (25 = 25 %). Fractional rates (FI 25.5) are
// truncated to the nearest integer per the raw-percent convention.
//
// NordicTaxProvider does not make network calls — all logic is local math.
// An external provider (Avalara, TaxJar) can be added by implementing
// TaxProvider and switching on TAX_PROVIDER.
type NordicTaxProvider struct {
defaultCountry string
}
// NewNordicTaxProvider returns a NordicTaxProvider. defaultCountry is used
// when DefaultTaxRate is called with an empty country code; it defaults to
// "SE" if empty.
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
if defaultCountry == "" {
defaultCountry = "SE"
}
return &NordicTaxProvider{defaultCountry: defaultCountry}
}
var _ TaxProvider = (*NordicTaxProvider)(nil)
func (p *NordicTaxProvider) Name() string { return "nordic" }
func (p *NordicTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// nordicStandardRates maps country code → default standard VAT rate (raw percent).
var nordicStandardRates = map[string]int{
"SE": 25,
"NO": 25,
"DK": 25,
"FI": 25,
}
func (p *NordicTaxProvider) DefaultTaxRate(country string) int {
if country == "" {
country = p.defaultCountry
}
if rate, ok := nordicStandardRates[country]; ok {
return rate
}
return nordicStandardRates[p.defaultCountry]
}
+113
View File
@@ -0,0 +1,113 @@
package cart
import (
"testing"
)
func TestNordicTaxProvider_Name(t *testing.T) {
p := NewNordicTaxProvider("SE")
if got := p.Name(); got != "nordic" {
t.Errorf("Name() = %q, want %q", got, "nordic")
}
}
func TestNordicTaxProvider_ComputeTax(t *testing.T) {
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{0, 25, 0, "zero total"},
{1250, 25, 250, "25 % VAT on 1250"},
{1000, 20, 166, "20 % VAT on 1000"},
{1200, 25, 240, "25 % VAT on 1200"},
{25000, 25, 5000, "25 % VAT on 25000"},
{100, 10, 9, "10 % VAT on 100"},
{100, 100, 50, "100 % VAT on 100"},
{100, 0, 0, "zero-rate"},
{100, -1, 0, "negative rate -> zero"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
got := p.ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
func TestNordicTaxProvider_DefaultTaxRate(t *testing.T) {
tests := []struct {
country string
want int
desc string
}{
{"SE", 25, "Sweden"},
{"NO", 25, "Norway"},
{"DK", 25, "Denmark"},
{"FI", 25, "Finland"},
{"", 25, "empty -> default SE"},
{"DE", 25, "unknown -> fallback SE"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
got := p.DefaultTaxRate(tt.country)
if got != tt.want {
t.Errorf("DefaultTaxRate(%q) [%s] = %d; want %d", tt.country, tt.desc, got, tt.want)
}
}
}
func TestStaticTaxProvider_ComputeTax(t *testing.T) {
p := NewStaticTaxProvider()
tests := []struct {
total int64
taxRate int
want int64
}{
{1250, 25, 250},
{25000, 25, 5000},
{1000, 20, 166},
{0, 25, 0},
}
for _, tt := range tests {
got := p.ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) = %d; want %d", tt.total, tt.taxRate, got, tt.want)
}
}
}
func TestStaticTaxProvider_DefaultTaxRate(t *testing.T) {
p := NewStaticTaxProvider()
if got := p.DefaultTaxRate("SE"); got != 0 {
t.Errorf("StaticTaxProvider.DefaultTaxRate(\"SE\") = %d; want 0", got)
}
}
func TestComputeTax(t *testing.T) {
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{1250, 25, 250, "canonical: 25 %"},
{25000, 25, 5000, "25k with 25 %% -> 5k tax"},
{0, 25, 0, "zero total"},
}
for _, tt := range tests {
got := ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
func TestTaxProviderImplementsInterface(t *testing.T) {
var p TaxProvider = NewNordicTaxProvider("SE")
_ = p
p2 := NewStaticTaxProvider()
_ = p2
}
+36 -2
View File
@@ -2,7 +2,9 @@ package discovery
import ( import (
"context" "context"
"os"
"slices" "slices"
"strings"
"sync" "sync"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
@@ -13,14 +15,33 @@ import (
toolsWatch "k8s.io/client-go/tools/watch" toolsWatch "k8s.io/client-go/tools/watch"
) )
// InClusterNamespace returns the namespace the current pod runs in: POD_NAMESPACE
// (downward API) if set, else the service-account namespace file mounted into
// every pod. Empty means "not in a cluster" — callers can treat that as
// all-namespaces or single-node. Pass the result to NewK8sDiscoveryInNamespace
// to keep pod discovery (and thus the required RBAC) scoped to one namespace.
func InClusterNamespace() string {
if ns := os.Getenv("POD_NAMESPACE"); ns != "" {
return ns
}
if b, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil {
return strings.TrimSpace(string(b))
}
return ""
}
type K8sDiscovery struct { type K8sDiscovery struct {
ctx context.Context ctx context.Context
client *kubernetes.Clientset client *kubernetes.Clientset
listOptions metav1.ListOptions listOptions metav1.ListOptions
// namespace scopes the pod list/watch. Empty means all namespaces (requires
// a cluster-scoped RBAC role); a specific namespace only needs a namespaced
// Role, which is the least-privilege option.
namespace string
} }
func (k *K8sDiscovery) Discover() ([]string, error) { func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("") return k.DiscoverInNamespace(k.namespace)
} }
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) { func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, k.listOptions) pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, k.listOptions)
@@ -46,7 +67,7 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
ipsThatAreReady := make(map[string]bool) ipsThatAreReady := make(map[string]bool)
m := sync.Mutex{} m := sync.Mutex{}
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) { watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, k.listOptions) return k.client.CoreV1().Pods(k.namespace).Watch(k.ctx, k.listOptions)
} }
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn}) watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil { if err != nil {
@@ -77,6 +98,8 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
return ch, nil return ch, nil
} }
// NewK8sDiscovery watches pods across ALL namespaces (label-filtered via
// listOptions). It requires a cluster-scoped RBAC role for pod list/watch.
func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOptions) *K8sDiscovery { func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOptions) *K8sDiscovery {
return &K8sDiscovery{ return &K8sDiscovery{
ctx: context.Background(), ctx: context.Background(),
@@ -84,3 +107,14 @@ func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOption
listOptions: listOptions, listOptions: listOptions,
} }
} }
// NewK8sDiscoveryInNamespace watches pods only within namespace, so it needs
// just a namespaced Role (pods: get/list/watch) rather than a ClusterRole.
func NewK8sDiscoveryInNamespace(client *kubernetes.Clientset, namespace string, listOptions metav1.ListOptions) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
listOptions: listOptions,
namespace: namespace,
}
}
+101
View File
@@ -233,6 +233,103 @@ func HandleIssueRefund(o *OrderGrain, m *messages.IssueRefund) error {
return nil return nil
} }
// HandleRequestExchange records an exchange request.
func HandleRequestExchange(o *OrderGrain, m *messages.RequestExchange) error {
if o.Status != StatusFulfilled && o.Status != StatusCompleted && o.Status != StatusPartiallyFulfilled {
return fmt.Errorf("order: cannot request an exchange in status %q", o.Status)
}
ex := Exchange{
ID: m.GetId(),
ReturnID: m.GetReturnId(),
Reason: m.GetReason(),
RequestedAt: msToString(m.GetAtMs()),
}
// 1. Process return lines
for _, rl := range m.GetReturnLines() {
line := o.findLine(rl.GetReference())
if line == nil {
return fmt.Errorf("order: exchange return references unknown line %q", rl.GetReference())
}
ex.ReturnLines = append(ex.ReturnLines, FulfillmentEntry{
Reference: rl.GetReference(),
Quantity: int(rl.GetQuantity()),
})
}
// 2. Process new lines (replacement items)
for _, nl := range m.GetNewLines() {
line := Line{
Reference: nl.GetReference(),
Sku: nl.GetSku(),
Name: nl.GetName(),
Quantity: int(nl.GetQuantity()),
UnitPrice: nl.GetUnitPrice(),
TaxRate: int(nl.GetTaxRate()),
TotalAmount: nl.GetTotalAmount(),
TotalTax: nl.GetTotalTax(),
}
ex.NewLines = append(ex.NewLines, line)
// Add the replacement line to the order so it can be fulfilled
o.Lines = append(o.Lines, line)
}
// 3. Since there are new lines that are not yet fulfilled, transition order status back to partially fulfilled
o.Status = StatusPartiallyFulfilled
o.Exchanges = append(o.Exchanges, ex)
o.touch(ex.RequestedAt)
return nil
}
// HandleEditOrderDetails updates addresses or shipping price.
func HandleEditOrderDetails(o *OrderGrain, m *messages.EditOrderDetails) error {
if o.Status == StatusCompleted || o.Status == StatusCancelled || o.Status == StatusRefunded {
return fmt.Errorf("order: cannot edit details in status %q", o.Status)
}
if s := m.GetShippingAddress(); len(s) > 0 {
o.ShippingAddress = append([]byte(nil), s...)
}
if b := m.GetBillingAddress(); len(b) > 0 {
o.BillingAddress = append([]byte(nil), b...)
}
if sp := m.GetShippingPrice(); sp > 0 {
found := false
var oldTotal int64
for i := range o.Lines {
if o.Lines[i].Name == "Delivery" {
oldTotal = o.Lines[i].TotalAmount
o.Lines[i].UnitPrice = sp
o.Lines[i].TotalAmount = sp * int64(o.Lines[i].Quantity)
o.Lines[i].TotalTax = ComputeTax(o.Lines[i].TotalAmount, o.Lines[i].TaxRate)
o.TotalAmount = o.TotalAmount - oldTotal + o.Lines[i].TotalAmount
found = true
break
}
}
if !found {
line := Line{
Reference: "delivery",
Sku: "delivery",
Name: "Delivery",
Quantity: 1,
UnitPrice: sp,
TaxRate: 25,
TotalAmount: sp,
TotalTax: ComputeTax(sp, 25),
}
o.Lines = append(o.Lines, line)
o.TotalAmount += sp
}
}
o.touch(msToString(m.GetAtMs()))
return nil
}
// RegisterMutations registers every order mutation handler with the registry so // RegisterMutations registers every order mutation handler with the registry so
// the grain pool can apply and replay them. // the grain pool can apply and replay them.
func RegisterMutations(reg actor.MutationRegistry) { func RegisterMutations(reg actor.MutationRegistry) {
@@ -245,5 +342,9 @@ func RegisterMutations(reg actor.MutationRegistry) {
actor.NewMutation(HandleCancelOrder), actor.NewMutation(HandleCancelOrder),
actor.NewMutation(HandleRequestReturn), actor.NewMutation(HandleRequestReturn),
actor.NewMutation(HandleIssueRefund), actor.NewMutation(HandleIssueRefund),
actor.NewMutation(HandleRequestExchange),
actor.NewMutation(HandleEditOrderDetails),
) )
} }
+12
View File
@@ -101,6 +101,16 @@ type Return struct {
RequestedAt string `json:"requestedAt,omitempty"` RequestedAt string `json:"requestedAt,omitempty"`
} }
// Exchange records an exchange request.
type Exchange struct {
ID string `json:"id"`
ReturnID string `json:"returnId"`
Reason string `json:"reason,omitempty"`
ReturnLines []FulfillmentEntry `json:"returnLines,omitempty"`
NewLines []Line `json:"newLines,omitempty"`
RequestedAt string `json:"requestedAt,omitempty"`
}
// Refund records a refund issued against the order. // Refund records a refund issued against the order.
type Refund struct { type Refund struct {
Provider string `json:"provider"` Provider string `json:"provider"`
@@ -137,8 +147,10 @@ type OrderGrain struct {
Payments []*Payment `json:"payments,omitempty"` Payments []*Payment `json:"payments,omitempty"`
Fulfillments []Fulfillment `json:"fulfillments,omitempty"` Fulfillments []Fulfillment `json:"fulfillments,omitempty"`
Returns []Return `json:"returns,omitempty"` Returns []Return `json:"returns,omitempty"`
Exchanges []Exchange `json:"exchanges,omitempty"`
Refunds []Refund `json:"refunds,omitempty"` Refunds []Refund `json:"refunds,omitempty"`
CapturedAmount int64 `json:"capturedAmount"` CapturedAmount int64 `json:"capturedAmount"`
RefundedAmount int64 `json:"refundedAmount"` RefundedAmount int64 `json:"refundedAmount"`
+30
View File
@@ -0,0 +1,30 @@
package order
import "git.k6n.net/mats/go-cart-actor/pkg/cart"
// TaxProvider computes taxes for orders and line items.
// The canonical definition is in pkg/cart; this is a re-export for
// backward compatibility.
type TaxProvider = cart.TaxProvider
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
// Re-exported from pkg/cart for backward compatibility.
func ComputeTax(total int64, taxRate int) int64 {
return cart.ComputeTax(total, taxRate)
}
// NordicTaxProvider implements TaxProvider for the Nordic countries.
// Re-exported from pkg/cart for backward compatibility.
type NordicTaxProvider = cart.NordicTaxProvider
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
return cart.NewNordicTaxProvider(defaultCountry)
}
// StaticTaxProvider is a stateless TaxProvider with no country awareness.
// Re-exported from pkg/cart for backward compatibility.
type StaticTaxProvider = cart.StaticTaxProvider
func NewStaticTaxProvider() *StaticTaxProvider {
return cart.NewStaticTaxProvider()
}
+34
View File
@@ -0,0 +1,34 @@
package order
import (
"testing"
)
// TestReExports verifies that the order package's type aliases and
// forwarders to pkg/cart compile and work correctly.
func TestReExports(t *testing.T) {
// Type alias: TaxProvider = cart.TaxProvider
var _ TaxProvider = NewNordicTaxProvider("SE")
var _ TaxProvider = NewStaticTaxProvider()
// NordicTaxProvider
p := NewNordicTaxProvider("SE")
if p.Name() != "nordic" {
t.Errorf("Name = %q", p.Name())
}
if got := p.DefaultTaxRate("SE"); got != 25 {
t.Errorf("DefaultTaxRate(SE) = %d, want 25", got)
}
if got := ComputeTax(1250, 25); got != 250 {
t.Errorf("ComputeTax(1250, 25) = %d, want 250", got)
}
// StaticTaxProvider
s := NewStaticTaxProvider()
if s.Name() != "static" {
t.Errorf("Name = %q", s.Name())
}
if got := s.DefaultTaxRate("SE"); got != 0 {
t.Errorf("DefaultTaxRate(SE) = %d, want 0", got)
}
}
+1
View File
@@ -16,6 +16,7 @@ func NewProfileMutationRegistry() actor.MutationRegistry {
actor.NewMutation(HandleLinkCart), actor.NewMutation(HandleLinkCart),
actor.NewMutation(HandleLinkCheckout), actor.NewMutation(HandleLinkCheckout),
actor.NewMutation(HandleLinkOrder), actor.NewMutation(HandleLinkOrder),
actor.NewMutation(HandleAnonymizeProfile),
) )
return reg return reg
} }
+20
View File
@@ -0,0 +1,20 @@
package profile
import (
"fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
)
// HandleAnonymizeProfile wipes all PII from the customer profile.
func HandleAnonymizeProfile(p *ProfileGrain, m *messages.AnonymizeProfile) error {
if m == nil {
return fmt.Errorf("AnonymizeProfile: nil payload")
}
p.Name = ""
p.Email = ""
p.Phone = ""
p.AvatarUrl = ""
p.Addresses = nil // Clear all addresses containing PII
return nil
}
+367 -5
View File
@@ -2,6 +2,8 @@ package promotions
import ( import (
"math" "math"
"slices"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/cart"
) )
@@ -28,7 +30,7 @@ type Effect interface {
// Apply mutates the cart for a qualifying action and returns the discount it // Apply mutates the cart for a qualifying action and returns the discount it
// took (nil for non-monetary effects such as free shipping) plus whether // took (nil for non-monetary effects such as free shipping) plus whether
// anything took effect worth recording. // anything took effect worth recording.
Apply(g *cart.CartGrain, a Action) (discount *cart.Price, applied bool) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (discount *cart.Price, applied bool)
// Progress reports how far the cart is from (further) unlocking this action, // Progress reports how far the cart is from (further) unlocking this action,
// as an open key/value payload. qualified says whether the owning rule // as an open key/value payload. qualified says whether the owning rule
// currently applies, letting the effect distinguish "remaining to unlock" // currently applies, letting the effect distinguish "remaining to unlock"
@@ -43,6 +45,8 @@ func defaultEffects() map[ActionType]Effect {
ActionPercentageDiscount: percentageDiscountEffect{}, ActionPercentageDiscount: percentageDiscountEffect{},
ActionTieredDiscount: tieredDiscountEffect{}, ActionTieredDiscount: tieredDiscountEffect{},
ActionFreeShipping: freeShippingEffect{}, ActionFreeShipping: freeShippingEffect{},
ActionBuyXGetY: buyXGetYEffect{},
ActionBundleDiscount: bundleDiscountEffect{},
} }
} }
@@ -72,7 +76,7 @@ func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationR
} }
recorded := false recorded := false
if res.Applicable { if res.Applicable {
if discount, applied := eff.Apply(g, a); applied { if discount, applied := eff.Apply(g, res.Rule, a); applied {
entry.Discount = discount entry.Discount = discount
recorded = true recorded = true
} }
@@ -106,7 +110,7 @@ type percentageDiscountEffect struct{}
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount } func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
func (percentageDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) { func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
d := applyPercentageDiscount(g, percentFromValue(a.Value)) d := applyPercentageDiscount(g, percentFromValue(a.Value))
return d, d != nil return d, d != nil
} }
@@ -124,7 +128,7 @@ type freeShippingEffect struct{}
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping } func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ Action) (*cart.Price, bool) { func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, bool) {
return nil, true return nil, true
} }
@@ -143,7 +147,7 @@ type tieredDiscountEffect struct{}
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount } func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
func (tieredDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) { func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
d := applyTieredDiscount(g, a) d := applyTieredDiscount(g, a)
return d, d != nil return d, d != nil
} }
@@ -366,3 +370,361 @@ func firstNum(m map[string]interface{}, keys ...string) float64 {
} }
return 0 return 0
} }
// ----------------------------
// buyXGetYEffect implementation
// ----------------------------
type buyXGetYEffect struct{}
func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY }
type unitItem struct {
item *cart.CartItem
price int64
}
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
config := a.Config
buy := int(firstNum(config, "buy"))
if buy <= 0 {
buy = 1
}
get := int(firstNum(config, "get"))
if get <= 0 {
get = 1
}
discount := firstNum(config, "discount")
if discount <= 0 {
discount = 100.0 // default free
}
eligible := eligibleItems(rule, g)
var units []unitItem
for _, item := range eligible {
for k := uint16(0); k < item.Quantity; k++ {
units = append(units, unitItem{
item: item,
price: item.Price.IncVat,
})
}
}
n := len(units)
numFree := (n / (buy + get)) * get
if numFree <= 0 {
return nil, false
}
// Sort units by price ascending so we discount the cheapest ones
slices.SortFunc(units, func(x, y unitItem) int {
if x.price < y.price {
return -1
}
if x.price > y.price {
return 1
}
return 0
})
totalDiscount := cart.NewPrice()
for i := 0; i < numFree; i++ {
unitDiscount := scalePrice(&units[i].item.Price, discount)
totalDiscount.Add(*unitDiscount)
}
if totalDiscount.IncVat <= 0 {
return nil, false
}
// Never discount below zero.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
return totalDiscount, true
}
func (buyXGetYEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
return nil, false
}
// ----------------------------
// bundleDiscountEffect implementation
// ----------------------------
type bundleDiscountEffect struct{}
func (bundleDiscountEffect) Type() ActionType { return ActionBundleDiscount }
func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
if a.BundleConfig == nil {
return nil, false
}
// 1. Represent all cart items as individual units
type bundleUnit struct {
item *cart.CartItem
used bool
}
var units []*bundleUnit
for _, item := range g.Items {
if item == nil {
continue
}
for k := uint16(0); k < item.Quantity; k++ {
units = append(units, &bundleUnit{
item: item,
used: false,
})
}
}
// 2. Greedy match bundles
var formedBundles [][]*cart.CartItem
for {
// Attempt to form one bundle
var matchedUnits []*bundleUnit
possible := true
for _, container := range a.BundleConfig.Containers {
needed := container.Quantity
found := 0
var containerMatched []*bundleUnit
// Find unused units matching container's qualifyingRules
for _, u := range units {
if u.used {
continue
}
// Check if this unit is already matched in this bundle instance to avoid double counting
alreadyMatched := false
for _, mu := range matchedUnits {
if mu == u {
alreadyMatched = true
break
}
}
if alreadyMatched {
continue
}
if matchQualifyingRule(container.QualifyingRules, u.item) {
containerMatched = append(containerMatched, u)
found++
if found == needed {
break
}
}
}
if found < needed {
if a.BundleConfig.RequireAllContainers {
possible = false
break
}
} else {
matchedUnits = append(matchedUnits, containerMatched...)
}
}
if !possible || len(matchedUnits) == 0 {
break
}
// Mark matched units as used
var bundleItems []*cart.CartItem
for _, mu := range matchedUnits {
mu.used = true
bundleItems = append(bundleItems, mu.item)
}
formedBundles = append(formedBundles, bundleItems)
}
if len(formedBundles) == 0 {
return nil, false
}
totalDiscount := cart.NewPrice()
for _, bundle := range formedBundles {
// Calculate the original bundle price
bundlePrice := cart.NewPrice()
for _, item := range bundle {
// Add a single unit's price
bundlePrice.Add(item.Price)
}
if bundlePrice.IncVat <= 0 {
continue
}
var bundleDiscount *cart.Price
switch a.BundleConfig.Pricing.Type {
case "fixed_price":
targetPriceOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
if bundlePrice.IncVat > targetPriceOre {
pct := float64(bundlePrice.IncVat-targetPriceOre) / float64(bundlePrice.IncVat) * 100
bundleDiscount = scalePrice(bundlePrice, pct)
}
case "percentage_discount":
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
case "fixed_discount":
discountOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
pct := float64(discountOre) / float64(bundlePrice.IncVat) * 100
bundleDiscount = scalePrice(bundlePrice, pct)
}
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
totalDiscount.Add(*bundleDiscount)
}
}
if totalDiscount.IncVat <= 0 {
return nil, false
}
// Never discount below zero.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
return totalDiscount, true
}
func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
return nil, false
}
// ----------------------------
// Helpers
// ----------------------------
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
var categories []string
var productIDs []string
var walk func(conds Conditions)
walk = func(conds Conditions) {
for _, c := range conds {
switch v := c.(type) {
case ConditionGroup:
walk(v.Conditions)
case BaseCondition:
if v.Type == CondProductCategory {
if s, ok := v.Value.AsString(); ok {
categories = append(categories, strings.ToLower(s))
} else if arr, ok := v.Value.AsStringSlice(); ok {
for _, s := range arr {
categories = append(categories, strings.ToLower(s))
}
}
} else if v.Type == CondProductID {
if s, ok := v.Value.AsString(); ok {
productIDs = append(productIDs, strings.ToLower(s))
} else if arr, ok := v.Value.AsStringSlice(); ok {
for _, s := range arr {
productIDs = append(productIDs, strings.ToLower(s))
}
}
}
}
}
}
walk(rule.Conditions)
var out []*cart.CartItem
for _, item := range g.Items {
if item == nil {
continue
}
match := true
if len(categories) > 0 {
cat := ""
if item.Meta != nil {
cat = strings.ToLower(item.Meta.Category)
}
found := false
for _, c := range categories {
if c == cat {
found = true
break
}
}
if !found {
match = false
}
}
if len(productIDs) > 0 {
sku := strings.ToLower(item.Sku)
found := false
for _, p := range productIDs {
if p == sku {
found = true
break
}
}
if !found {
match = false
}
}
if match {
out = append(out, item)
}
}
return out
}
func matchQualifyingRule(rule BundleQualifyingRules, item *cart.CartItem) bool {
if item == nil {
return false
}
valStr, isStr := rule.Value.(string)
var valSlice []string
if !isStr {
if rawSlice, ok := rule.Value.([]interface{}); ok {
for _, val := range rawSlice {
if s, ok := val.(string); ok {
valSlice = append(valSlice, strings.ToLower(s))
}
}
} else if strSlice, ok := rule.Value.([]string); ok {
for _, s := range strSlice {
valSlice = append(valSlice, strings.ToLower(s))
}
}
} else {
valStr = strings.ToLower(valStr)
}
switch rule.Type {
case "category":
if item.Meta == nil {
return false
}
cat := strings.ToLower(item.Meta.Category)
if isStr {
return cat == valStr
}
for _, c := range valSlice {
if cat == c {
return true
}
}
return false
case "product_ids":
sku := strings.ToLower(item.Sku)
if isStr {
return sku == valStr
}
for _, s := range valSlice {
if sku == s {
return true
}
}
return false
case "all":
return true
default:
return false
}
}
+101
View File
@@ -146,3 +146,104 @@ func TestApplyResultsRecordsEffects(t *testing.T) {
} }
}) })
} }
func TestBuyXGetYEffect(t *testing.T) {
rule := PromotionRule{
ID: "buy2get1",
Name: "Buy 2 Get 1 Free",
Status: StatusActive,
Actions: []Action{
{
ID: "buy2get1-action",
Type: ActionBuyXGetY,
Config: map[string]interface{}{
"buy": 2.0,
"get": 1.0,
"discount": 100.0,
},
},
},
}
svc := NewPromotionService(nil)
// Cart with 3 items of price 1000 each. Buy 2 get 1 free -> 1 is free -> 1000 discount.
g := cart.NewCartGrain(1, timeZero())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "SKU1", Quantity: 3, Price: *cart.NewPriceFromIncVat(1000, 25)},
}
g.UpdateTotals()
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
if got := g.TotalDiscount.IncVat; got != 1000 {
t.Errorf("discount = %d, want 1000", got)
}
if got := g.TotalPrice.IncVat; got != 2000 {
t.Errorf("total price = %d, want 2000", got)
}
}
func TestBundleDiscountEffect(t *testing.T) {
rule := PromotionRule{
ID: "bundle-deal",
Name: "Bundle Deal",
Status: StatusActive,
Actions: []Action{
{
ID: "bundle-action",
Type: ActionBundleDiscount,
BundleConfig: &BundleConfig{
Containers: []BundleContainer{
{
ID: "shoes",
Name: "Shoes",
Quantity: 1,
SelectionType: "any",
QualifyingRules: BundleQualifyingRules{
Type: "category",
Value: "shoes",
},
},
{
ID: "socks",
Name: "Socks",
Quantity: 2,
SelectionType: "any",
QualifyingRules: BundleQualifyingRules{
Type: "category",
Value: "socks",
},
},
},
Pricing: BundlePricing{
Type: "fixed_price",
Value: 40.0, // 40 kr -> 4000 ore
},
RequireAllContainers: true,
},
},
},
}
svc := NewPromotionService(nil)
// Cart with 1 shoe (5000) and 2 socks (1000 each). Total original = 7000. Bundle fixed price is 4000. Discount = 3000.
g := cart.NewCartGrain(1, timeZero())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "shoe-1", Quantity: 1, Price: *cart.NewPriceFromIncVat(5000, 25), Meta: &cart.ItemMeta{Category: "shoes"}},
{Id: 2, Sku: "socks-1", Quantity: 2, Price: *cart.NewPriceFromIncVat(1000, 25), Meta: &cart.ItemMeta{Category: "socks"}},
}
g.UpdateTotals()
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
if got := g.TotalDiscount.IncVat; got != 3000 {
t.Errorf("discount = %d, want 3000", got)
}
if got := g.TotalPrice.IncVat; got != 4000 {
t.Errorf("total price = %d, want 4000", got)
}
}
+34 -2
View File
@@ -42,6 +42,7 @@ type PromotionEvalContext struct {
CustomerLifetimeValue float64 CustomerLifetimeValue float64
OrderCount int OrderCount int
Now time.Time Now time.Time
CouponCodes []string
} }
// ContextOption allows customization of fields when building a PromotionEvalContext. // ContextOption allows customization of fields when building a PromotionEvalContext.
@@ -67,6 +68,11 @@ func WithNow(t time.Time) ContextOption {
return func(c *PromotionEvalContext) { c.Now = t } return func(c *PromotionEvalContext) { c.Now = t }
} }
// WithCouponCodes sets active coupon codes.
func WithCouponCodes(codes []string) ContextOption {
return func(c *PromotionEvalContext) { c.CouponCodes = codes }
}
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata. // NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext { func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
ctx := &PromotionEvalContext{ ctx := &PromotionEvalContext{
@@ -74,6 +80,7 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
CartTotalIncVat: 0, CartTotalIncVat: 0,
TotalItemQuantity: 0, TotalItemQuantity: 0,
Now: time.Now(), Now: time.Now(),
CouponCodes: make([]string, 0),
} }
if g.TotalPrice != nil { if g.TotalPrice != nil {
ctx.CartTotalIncVat = g.TotalPrice.IncVat ctx.CartTotalIncVat = g.TotalPrice.IncVat
@@ -91,6 +98,9 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
}) })
ctx.TotalItemQuantity += uint32(it.Quantity) ctx.TotalItemQuantity += uint32(it.Quantity)
} }
for _, v := range g.Vouchers {
ctx.CouponCodes = append(ctx.CouponCodes, v.Code)
}
for _, o := range opts { for _, o := range opts {
o(ctx) o(ctx)
} }
@@ -106,18 +116,29 @@ type PromotionService struct {
// even one that has already applied (e.g. a tiered discount at 5% pointing at // even one that has already applied (e.g. a tiered discount at 5% pointing at
// the next 9% tier). Register a new Effect to add a new action type. // the next 9% tier). Register a new Effect to add a new action type.
effects map[ActionType]Effect effects map[ActionType]Effect
// DefaultTaxProvider provides the VAT rate used when constructing synthetic
// carts for stateless evaluation and preview. When nil, falls back to 25 %.
DefaultTaxProvider cart.TaxProvider
} }
// NewPromotionService constructs a PromotionService with an optional tracer and // NewPromotionService constructs a PromotionService with an optional tracer and
// the built-in effect handlers. // the built-in effect handlers.
func NewPromotionService(t Tracer) *PromotionService { // NewPromotionService constructs a PromotionService with an optional tracer.
// When tp is non-nil, it is used as DefaultTaxProvider for synthetic cart
// construction (stateless evaluation, preview); otherwise the service falls
// back to 25 % VAT.
func NewPromotionService(t Tracer, tp ...cart.TaxProvider) *PromotionService {
if t == nil { if t == nil {
t = NoopTracer{} t = NoopTracer{}
} }
return &PromotionService{ s := &PromotionService{
tracer: t, tracer: t,
effects: defaultEffects(), effects: defaultEffects(),
} }
if len(tp) > 0 && tp[0] != nil {
s.DefaultTaxProvider = tp[0]
}
return s
} }
// RegisterEffect adds (or overrides) the handler for an action type, so new // RegisterEffect adds (or overrides) the handler for an action type, so new
@@ -360,11 +381,22 @@ func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
return evalDayOfWeek(ctx.Now, b) return evalDayOfWeek(ctx.Now, b)
case CondTimeOfDay: case CondTimeOfDay:
return evalTimeOfDay(ctx.Now, b) return evalTimeOfDay(ctx.Now, b)
case CondCouponCode:
return evalAnyCouponMatch(ctx.CouponCodes, b)
default: default:
return false return false
} }
} }
func evalAnyCouponMatch(codes []string, b BaseCondition) bool {
for _, code := range codes {
if evalStringCompare(code, b) {
return true
}
}
return false
}
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool { func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
if slices.ContainsFunc(ctx.Items, pred) { if slices.ContainsFunc(ctx.Items, pred) {
return true return true
+39
View File
@@ -442,6 +442,45 @@ func TestDateRangeCondition(t *testing.T) {
} }
} }
func TestCouponCodeCondition(t *testing.T) {
rule := PromotionRule{
ID: "coupon-promo",
Name: "Coupon Promo",
Status: StatusActive,
Conditions: Conditions{
BaseCondition{
ID: "c",
Type: CondCouponCode,
Operator: OpEquals,
Value: cvString("SAVE50"),
},
},
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
}
svc := NewPromotionService(nil)
// Context with matching coupon code
ctxMatched := &PromotionEvalContext{
CouponCodes: []string{"SAVE50"},
Now: time.Now(),
}
resMatched := svc.EvaluateRule(rule, ctxMatched)
if !resMatched.Applicable {
t.Errorf("expected coupon code rule to apply with SAVE50")
}
// Context without matching coupon code
ctxUnmatched := &PromotionEvalContext{
CouponCodes: []string{"SAVE20"},
Now: time.Now(),
}
resUnmatched := svc.EvaluateRule(rule, ctxUnmatched)
if resUnmatched.Applicable {
t.Errorf("expected coupon code rule NOT to apply with SAVE20")
}
}
// --- Utilities ------------------------------------------------------------- // --- Utilities -------------------------------------------------------------
func ptr(s string) *string { return &s } func ptr(s string) *string { return &s }
+15 -4
View File
@@ -52,7 +52,7 @@ type EvaluateResponse struct {
// Evaluate runs rules against a synthetic cart built from req and returns the // Evaluate runs rules against a synthetic cart built from req and returns the
// resulting totals and effects without touching any real cart state. // resulting totals and effects without touching any real cart state.
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse { func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
g := req.toCart() g := req.toCart(s.DefaultTaxProvider)
g.UpdateTotals() g.UpdateTotals()
ctx := NewContextFromCart(g, req.contextOptions()...) ctx := NewContextFromCart(g, req.contextOptions()...)
results, _ := s.EvaluateAll(rules, ctx) results, _ := s.EvaluateAll(rules, ctx)
@@ -65,7 +65,8 @@ func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest)
} }
// toCart materialises the request into a throwaway CartGrain. // toCart materialises the request into a throwaway CartGrain.
func (req EvaluateRequest) toCart() *cart.CartGrain { // tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %.
func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
now := time.Now() now := time.Now()
if req.Now != nil { if req.Now != nil {
now = *req.Now now = *req.Now
@@ -78,7 +79,7 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
} }
vat := it.VatRate vat := it.VatRate
if vat == 0 { if vat == 0 {
vat = 25 vat = defaultVatRate(tp)
} }
item := &cart.CartItem{ item := &cart.CartItem{
Sku: it.Sku, Sku: it.Sku,
@@ -91,15 +92,25 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
g.Items = append(g.Items, item) g.Items = append(g.Items, item)
} }
if len(g.Items) == 0 && req.CartTotalIncVat > 0 { if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
vat := defaultVatRate(tp)
g.Items = append(g.Items, &cart.CartItem{ g.Items = append(g.Items, &cart.CartItem{
Sku: "synthetic", Sku: "synthetic",
Quantity: 1, Quantity: 1,
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, 25), Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, vat),
}) })
} }
return g return g
} }
// defaultVatRate returns the default VAT rate (as a float32 for NewPriceFromIncVat)
// from the provider, or 25 if none is configured.
func defaultVatRate(tp cart.TaxProvider) float32 {
if tp == nil {
return 25
}
return float32(tp.DefaultTaxRate(""))
}
// contextOptions maps the optional customer/time fields onto context options. // contextOptions maps the optional customer/time fields onto context options.
func (req EvaluateRequest) contextOptions() []ContextOption { func (req EvaluateRequest) contextOptions() []ContextOption {
var opts []ContextOption var opts []ContextOption
+9
View File
@@ -133,6 +133,15 @@ func (s *Server) initialize(params json.RawMessage) map[string]any {
} }
} }
// defaultVatRate returns the default VAT rate from the eval service's
// configured tax provider, falling back to 25 if none is set.
func (s *Server) defaultVatRate() float32 {
if s.eval == nil || s.eval.DefaultTaxProvider == nil {
return 25
}
return float32(s.eval.DefaultTaxProvider.DefaultTaxRate(""))
}
func isBatch(body []byte) bool { func isBatch(body []byte) bool {
for _, b := range body { for _, b := range body {
switch b { switch b {
+2 -2
View File
@@ -86,7 +86,7 @@ func (s *Server) buildPublicTools() []tool {
if err := decode(args, &a); err != nil { if err := decode(args, &a); err != nil {
return nil, err return nil, err
} }
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity) g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
opts := []promotions.ContextOption{promotions.WithNow(time.Now())} opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
if a.CustomerSegment != "" { if a.CustomerSegment != "" {
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
@@ -141,7 +141,7 @@ func (s *Server) buildPublicTools() []tool {
rules = filtered rules = filtered
} }
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity) g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
opts := []promotions.ContextOption{promotions.WithNow(time.Now())} opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
if a.CustomerSegment != "" { if a.CustomerSegment != "" {
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
+9 -5
View File
@@ -190,7 +190,7 @@ func (s *Server) buildTools() []tool {
if err := decode(args, &a); err != nil { if err := decode(args, &a); err != nil {
return nil, err return nil, err
} }
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity) g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
opts := []promotions.ContextOption{promotions.WithNow(time.Now())} opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
if a.CustomerSegment != "" { if a.CustomerSegment != "" {
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
@@ -210,15 +210,19 @@ func (s *Server) buildTools() []tool {
} }
} }
// syntheticCart builds a one-line cart whose gross total is incVat öre (25% VAT // syntheticCart builds a one-line cart whose gross total is incVat ore so the
// assumed) so the promotion engine can be evaluated against an arbitrary total. // promotion engine can be evaluated against an arbitrary total. defaultVatRate
func syntheticCart(incVat int64, qty int) *cart.CartGrain { // is the VAT rate as a float32 percentage (e.g. 25 = 25 %).
func syntheticCart(incVat int64, qty int, defaultVatRate float32) *cart.CartGrain {
if qty <= 0 { if qty <= 0 {
qty = 1 qty = 1
} }
if defaultVatRate == 0 {
defaultVatRate = 25
}
g := cart.NewCartGrain(0, time.Now()) g := cart.NewCartGrain(0, time.Now())
g.Items = []*cart.CartItem{ g.Items = []*cart.CartItem{
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, 25)}, {Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, defaultVatRate)},
} }
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by // Quantity > 1 would multiply the row total; keep the gross equal to incVat by
// pricing a single unit at the full total. // pricing a single unit at the full total.
+1
View File
@@ -41,6 +41,7 @@ const (
CondDateRange ConditionType = "date_range" CondDateRange ConditionType = "date_range"
CondDayOfWeek ConditionType = "day_of_week" CondDayOfWeek ConditionType = "day_of_week"
CondTimeOfDay ConditionType = "time_of_day" CondTimeOfDay ConditionType = "time_of_day"
CondCouponCode ConditionType = "coupon_code"
CondGroup ConditionType = "group" // synthetic value for groups CondGroup ConditionType = "group" // synthetic value for groups
) )
-3
View File
@@ -13,7 +13,6 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/control" messages "git.k6n.net/mats/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"google.golang.org/grpc" "google.golang.org/grpc"
@@ -39,7 +38,6 @@ const name = "proxy"
var ( var (
tracer = otel.Tracer(name) tracer = otel.Tracer(name)
meter = otel.Meter(name) meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
) )
// MockResponseWriter implements http.ResponseWriter to capture responses for proxy calls. // MockResponseWriter implements http.ResponseWriter to capture responses for proxy calls.
@@ -292,7 +290,6 @@ func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request,
attribute.String("method", r.Method), attribute.String("method", r.Method),
attribute.String("target", target), attribute.String("target", target),
) )
logger.InfoContext(ctx, "proxying request", "cartid", id, "host", h.host, "method", r.Method)
var bdy io.Reader = r.Body var bdy io.Reader = r.Body
if customBody != nil { if customBody != nil {
+88
View File
@@ -0,0 +1,88 @@
// Package telemetry holds shared OpenTelemetry setup helpers used by the
// service main packages (cart, checkout, profile, …).
package telemetry
import (
"context"
"math/rand/v2"
"os"
"strconv"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/sdk/log"
)
// NewLoggerProvider builds the OTLP log provider from the environment, or returns
// (nil, nil) when logs are disabled — in which case the caller MUST skip
// global.SetLoggerProvider and the shutdown registration.
//
// Env:
//
// OTEL_LOGS_ENABLED "1"/"true"/"yes"/"on" to enable the OTLP log pipeline.
// Default OFF: the batch processor clones its record ring
// on every export, which profiling showed to be the
// dominant heap allocator in these services. Traces and
// metrics are always on regardless of this flag.
// OTEL_LOGS_SAMPLE_RATIO fraction of records to export, 0.01.0 (default 1.0).
// e.g. 0.1 keeps ~10% of logs. Dropped records never reach
// the batch ring, so allocation churn falls ~proportionally.
func NewLoggerProvider(ctx context.Context) (*log.LoggerProvider, error) {
if !logsEnabled() {
return nil, nil
}
exporter, err := otlploggrpc.New(ctx)
if err != nil {
return nil, err
}
var proc log.Processor = log.NewBatchProcessor(exporter)
if ratio := sampleRatio(); ratio < 1.0 {
proc = &samplingProcessor{next: proc, ratio: ratio}
}
return log.NewLoggerProvider(log.WithProcessor(proc)), nil
}
func logsEnabled() bool {
switch os.Getenv("OTEL_LOGS_ENABLED") {
case "1", "true", "TRUE", "True", "yes", "on":
return true
default:
return false
}
}
func sampleRatio() float64 {
v := os.Getenv("OTEL_LOGS_SAMPLE_RATIO")
if v == "" {
return 1.0
}
r, err := strconv.ParseFloat(v, 64)
if err != nil || r < 0 {
return 1.0
}
if r > 1 {
return 1.0
}
return r
}
// samplingProcessor forwards a random `ratio` fraction of records to the wrapped
// processor and drops the rest before they reach the (allocation-heavy) batch
// ring. This is head sampling: cheap, stateless, and per-record independent.
type samplingProcessor struct {
next log.Processor
ratio float64
}
func (s *samplingProcessor) Enabled(ctx context.Context, param log.EnabledParameters) bool {
return s.next.Enabled(ctx, param)
}
func (s *samplingProcessor) OnEmit(ctx context.Context, record *log.Record) error {
if rand.Float64() >= s.ratio {
return nil // dropped by sampling
}
return s.next.OnEmit(ctx, record)
}
func (s *samplingProcessor) Shutdown(ctx context.Context) error { return s.next.Shutdown(ctx) }
func (s *samplingProcessor) ForceFlush(ctx context.Context) error { return s.next.ForceFlush(ctx) }
+19 -30
View File
@@ -1,4 +1,4 @@
package main package telemetry
import ( import (
"context" "context"
@@ -6,22 +6,24 @@ import (
"time" "time"
"go.opentelemetry.io/otel" "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/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/log/global" "go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace"
) )
// setupOTelSDK bootstraps the OpenTelemetry pipeline. // SetupOTelSDK bootstraps the OpenTelemetry pipeline (propagator + traces +
// If it does not return an error, make sure to call shutdown for proper cleanup. // metrics, and an opt-in logger — see NewLoggerProvider). It is shared by every
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) { // service main package; the service name comes from OTEL_RESOURCE_ATTRIBUTES, so
// the same setup works everywhere. If it returns no error, call the returned
// shutdown for proper cleanup.
func SetupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
var shutdownFuncs []func(context.Context) error var shutdownFuncs []func(context.Context) error
var err error var err error
// shutdown calls the registered cleanup functions, joining their errors.
shutdown := func(ctx context.Context) error { shutdown := func(ctx context.Context) error {
var err error var err error
for _, fn := range shutdownFuncs { for _, fn := range shutdownFuncs {
@@ -31,12 +33,12 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
return err return err
} }
// handleErr runs shutdown and surfaces all errors.
handleErr := func(inErr error) { handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx)) err = errors.Join(inErr, shutdown(ctx))
} }
prop := newPropagator() otel.SetTextMapPropagator(newPropagator())
otel.SetTextMapPropagator(prop)
tracerProvider, err := newTracerProvider() tracerProvider, err := newTracerProvider()
if err != nil { if err != nil {
@@ -54,13 +56,17 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown) shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider) otel.SetMeterProvider(meterProvider)
loggerProvider, err := newLoggerProvider() // Logger provider is opt-in via OTEL_LOGS_ENABLED; nil means logs are off.
// Traces + metrics above are always on.
loggerProvider, err := NewLoggerProvider(ctx)
if err != nil { if err != nil {
handleErr(err) handleErr(err)
return shutdown, err return shutdown, err
} }
if loggerProvider != nil {
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown) shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider) global.SetLoggerProvider(loggerProvider)
}
return shutdown, err return shutdown, err
} }
@@ -77,12 +83,9 @@ func newTracerProvider() (*trace.TracerProvider, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return trace.NewTracerProvider(
tracerProvider := trace.NewTracerProvider( trace.WithBatcher(traceExporter, trace.WithBatchTimeout(time.Second)),
trace.WithBatcher(traceExporter, ), nil
trace.WithBatchTimeout(time.Second)),
)
return tracerProvider, nil
} }
func newMeterProvider() (*metric.MeterProvider, error) { func newMeterProvider() (*metric.MeterProvider, error) {
@@ -90,19 +93,5 @@ func newMeterProvider() (*metric.MeterProvider, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter))), nil
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
} }
+2
View File
@@ -41,6 +41,8 @@ message AddItem {
// custom_fields holds optional user-supplied input fields for this line // custom_fields holds optional user-supplied input fields for this line
// (e.g. engraving text, configurator notes), keyed by field name. // (e.g. engraving text, configurator notes), keyed by field name.
map<string, string> custom_fields = 28; map<string, string> custom_fields = 28;
bool inventory_tracked = 29;
bool drop_ship = 30;
} }
message RemoveItem { uint32 Id = 1; } message RemoveItem { uint32 Id = 1; }
+148 -127
View File
@@ -94,6 +94,8 @@ type AddItem struct {
// custom_fields holds optional user-supplied input fields for this line // custom_fields holds optional user-supplied input fields for this line
// (e.g. engraving text, configurator notes), keyed by field name. // (e.g. engraving text, configurator notes), keyed by field name.
CustomFields map[string]string `protobuf:"bytes,28,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` CustomFields map[string]string `protobuf:"bytes,28,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
InventoryTracked bool `protobuf:"varint,29,opt,name=inventory_tracked,json=inventoryTracked,proto3" json:"inventory_tracked,omitempty"`
DropShip bool `protobuf:"varint,30,opt,name=drop_ship,json=dropShip,proto3" json:"drop_ship,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -324,6 +326,20 @@ func (x *AddItem) GetCustomFields() map[string]string {
return nil return nil
} }
func (x *AddItem) GetInventoryTracked() bool {
if x != nil {
return x.InventoryTracked
}
return false
}
func (x *AddItem) GetDropShip() bool {
if x != nil {
return x.DropShip
}
return false
}
type RemoveItem struct { type RemoveItem struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"` Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
@@ -1113,7 +1129,7 @@ var file_cart_proto_rawDesc = string([]byte{
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72,
0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xe0, 0x07, 0x0a, 0x07, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xaa, 0x08, 0x0a, 0x07,
0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74, 0x65, 0x6d, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64,
0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01,
@@ -1168,133 +1184,138 @@ var file_cart_proto_rawDesc = string([]byte{
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74,
0x65, 0x6d, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x65, 0x6d, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45,
0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
0x64, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x69, 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x5f,
0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69,
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x12,
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x1b, 0x0a, 0x09, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x73, 0x68, 0x69, 0x70, 0x18, 0x1e, 0x20, 0x01,
0x02, 0x38, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a, 0x28, 0x08, 0x52, 0x08, 0x64, 0x72, 0x6f, 0x70, 0x53, 0x68, 0x69, 0x70, 0x1a, 0x3f, 0x0a, 0x11,
0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72,
0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x09, 0x0a,
0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f,
0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49,
0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f,
0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f,
0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01,
0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e,
0x22, 0x27, 0x0a, 0x15, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02,
0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04,
0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65,
0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28,
0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x22, 0x27, 0x0a, 0x15, 0x52, 0x65,
0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x5d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b,
0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x63, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52,
0x02, 0x69, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49,
0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12,
0x5d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73,
0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x2e, 0x43,
0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x3f,
0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e,
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22,
0x71, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41,
0x64, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09,
0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72,
0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
0x63, 0x65, 0x22, 0x7c, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72,
0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x76, 0x6f,
0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09,
0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x20,
0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69,
0x64, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73,
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12,
0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x02, 0x69,
0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e, 0x67,
0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x65,
0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e,
0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73,
0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x04, 0x64, 0x61,
0x74, 0x61, 0x18, 0x04, 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, 0x04,
0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x22, 0xa8, 0x07, 0x0a, 0x08,
0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x40, 0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61,
0x72, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63,
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x65,
0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52,
0x09, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x12, 0x33, 0x0a, 0x08, 0x61, 0x64,
0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63,
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64,
0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x07, 0x61, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12,
0x3c, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x03,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x48,
0x00, 0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x48, 0x0a,
0x0f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79,
0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61,
0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x00, 0x52, 0x0e, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51,
0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3a, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x75,
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63,
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x74, 0x55, 0x73, 0x65,
0x65, 0x6c, 0x64, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x72, 0x49, 0x64, 0x12, 0x4c, 0x0a, 0x11, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d,
0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
0x65, 0x6c, 0x64, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c,
0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x52, 0x0f, 0x6c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e,
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x67, 0x12, 0x5f, 0x0a, 0x18, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x6c, 0x69, 0x6e, 0x65,
0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x71, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x07, 0x20,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x74, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x65, 0x6d, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x18, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x15, 0x72, 0x65, 0x6d,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69,
0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x6e, 0x67, 0x12, 0x51, 0x0a, 0x12, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x6f, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20,
0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x22, 0x7c, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x56, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53,
0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64,
0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x48, 0x00, 0x52, 0x11, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x66, 0x0a, 0x1b, 0x73, 0x65, 0x74, 0x5f, 0x6c, 0x69, 0x6e,
0x12, 0x22, 0x0a, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69,
0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x63, 0x61, 0x72,
0x75, 0x6c, 0x65, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69,
0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x64, 0x73, 0x48, 0x00, 0x52, 0x17, 0x73, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65,
0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x3c, 0x0a,
0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x14, 0x20, 0x01,
0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52,
0x09, 0x48, 0x00, 0x52, 0x02, 0x69, 0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66, 0x0a, 0x61, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x45, 0x0a, 0x0e, 0x72,
0x66, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x15, 0x20,
0x52, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x72, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68,
0x12, 0x28, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x65, 0x72, 0x12, 0x6a, 0x0a, 0x1b, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x5f, 0x73, 0x75, 0x62,
0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c,
0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x73, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d,
0x64, 0x22, 0xa8, 0x07, 0x0a, 0x08, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x40, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75,
0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c,
0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x73, 0x48, 0x00, 0x52, 0x19, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63,
0x65, 0x73, 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x42, 0x06,
0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36,
0x12, 0x33, 0x0a, 0x08, 0x61, 0x64, 0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61,
0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63,
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x07, 0x61, 0x64, 0x61, 0x72, 0x74, 0x3b, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x3c, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x69, 0x74, 0x65, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72,
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76,
0x65, 0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49,
0x74, 0x65, 0x6d, 0x12, 0x48, 0x0a, 0x0f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x71, 0x75,
0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x63,
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x68, 0x61,
0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x00, 0x52, 0x0e, 0x63,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3a, 0x0a,
0x0b, 0x73, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, 0x00, 0x52, 0x09,
0x73, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x4c, 0x0a, 0x11, 0x6c, 0x69, 0x6e,
0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x06,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72,
0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x0f, 0x6c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d,
0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x5f, 0x0a, 0x18, 0x72, 0x65, 0x6d, 0x6f, 0x76,
0x65, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b,
0x69, 0x6e, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x63, 0x61, 0x72, 0x74,
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48,
0x00, 0x52, 0x15, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65,
0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x51, 0x0a, 0x12, 0x73, 0x75, 0x62, 0x73,
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x08,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x48, 0x00, 0x52, 0x11, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72,
0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x66, 0x0a, 0x1b, 0x73,
0x65, 0x74, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x75, 0x73,
0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x26, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74,
0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x48, 0x00, 0x52, 0x17, 0x73, 0x65, 0x74, 0x4c,
0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65,
0x6c, 0x64, 0x73, 0x12, 0x3c, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68,
0x65, 0x72, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63,
0x68, 0x65, 0x72, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
0x72, 0x12, 0x45, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x76, 0x6f, 0x75, 0x63,
0x68, 0x65, 0x72, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x61, 0x72, 0x74,
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76,
0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x6a, 0x0a, 0x1b, 0x75, 0x70, 0x73, 0x65,
0x72, 0x74, 0x5f, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e,
0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70,
0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x48, 0x00, 0x52, 0x19, 0x75, 0x70, 0x73, 0x65, 0x72,
0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74,
0x61, 0x69, 0x6c, 0x73, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x39, 0x5a, 0x37,
0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73,
0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x61, 0x72, 0x74, 0x3b, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}) })
var ( var (
+21
View File
@@ -96,3 +96,24 @@ message IssueRefund {
string return_id = 4; // optional linked RMA string return_id = 4; // optional linked RMA
int64 at_ms = 5; int64 at_ms = 5;
} }
// RequestExchange opens an RMA return and reserves/adds replacement lines.
message RequestExchange {
string id = 1;
string return_id = 2;
string reason = 3;
repeated FulfillmentLine return_lines = 4;
repeated OrderLine new_lines = 5;
int64 at_ms = 6;
}
// EditOrderDetails updates address/shipping price post-placement.
message EditOrderDetails {
bytes shipping_address = 1;
bytes billing_address = 2;
int64 shipping_price = 3;
int64 at_ms = 4;
}
+195 -13
View File
@@ -781,6 +781,160 @@ func (x *IssueRefund) GetAtMs() int64 {
return 0 return 0
} }
// RequestExchange opens an RMA return and reserves/adds replacement lines.
type RequestExchange struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
ReturnId string `protobuf:"bytes,2,opt,name=return_id,json=returnId,proto3" json:"return_id,omitempty"`
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
ReturnLines []*FulfillmentLine `protobuf:"bytes,4,rep,name=return_lines,json=returnLines,proto3" json:"return_lines,omitempty"`
NewLines []*OrderLine `protobuf:"bytes,5,rep,name=new_lines,json=newLines,proto3" json:"new_lines,omitempty"`
AtMs int64 `protobuf:"varint,6,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RequestExchange) Reset() {
*x = RequestExchange{}
mi := &file_order_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RequestExchange) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RequestExchange) ProtoMessage() {}
func (x *RequestExchange) ProtoReflect() protoreflect.Message {
mi := &file_order_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RequestExchange.ProtoReflect.Descriptor instead.
func (*RequestExchange) Descriptor() ([]byte, []int) {
return file_order_proto_rawDescGZIP(), []int{10}
}
func (x *RequestExchange) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *RequestExchange) GetReturnId() string {
if x != nil {
return x.ReturnId
}
return ""
}
func (x *RequestExchange) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
func (x *RequestExchange) GetReturnLines() []*FulfillmentLine {
if x != nil {
return x.ReturnLines
}
return nil
}
func (x *RequestExchange) GetNewLines() []*OrderLine {
if x != nil {
return x.NewLines
}
return nil
}
func (x *RequestExchange) GetAtMs() int64 {
if x != nil {
return x.AtMs
}
return 0
}
// EditOrderDetails updates address/shipping price post-placement.
type EditOrderDetails struct {
state protoimpl.MessageState `protogen:"open.v1"`
ShippingAddress []byte `protobuf:"bytes,1,opt,name=shipping_address,json=shippingAddress,proto3" json:"shipping_address,omitempty"`
BillingAddress []byte `protobuf:"bytes,2,opt,name=billing_address,json=billingAddress,proto3" json:"billing_address,omitempty"`
ShippingPrice int64 `protobuf:"varint,3,opt,name=shipping_price,json=shippingPrice,proto3" json:"shipping_price,omitempty"`
AtMs int64 `protobuf:"varint,4,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EditOrderDetails) Reset() {
*x = EditOrderDetails{}
mi := &file_order_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EditOrderDetails) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EditOrderDetails) ProtoMessage() {}
func (x *EditOrderDetails) ProtoReflect() protoreflect.Message {
mi := &file_order_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EditOrderDetails.ProtoReflect.Descriptor instead.
func (*EditOrderDetails) Descriptor() ([]byte, []int) {
return file_order_proto_rawDescGZIP(), []int{11}
}
func (x *EditOrderDetails) GetShippingAddress() []byte {
if x != nil {
return x.ShippingAddress
}
return nil
}
func (x *EditOrderDetails) GetBillingAddress() []byte {
if x != nil {
return x.BillingAddress
}
return nil
}
func (x *EditOrderDetails) GetShippingPrice() int64 {
if x != nil {
return x.ShippingPrice
}
return 0
}
func (x *EditOrderDetails) GetAtMs() int64 {
if x != nil {
return x.AtMs
}
return 0
}
var File_order_proto protoreflect.FileDescriptor var File_order_proto protoreflect.FileDescriptor
var file_order_proto_rawDesc = string([]byte{ var file_order_proto_rawDesc = string([]byte{
@@ -886,12 +1040,36 @@ var file_order_proto_rawDesc = string([]byte{
0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65,
0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72,
0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73,
0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xe7, 0x01, 0x0a,
0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x0f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a,
0x33, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72,
0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x42, 0x0a, 0x0c, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f,
0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72,
0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c,
0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x0b, 0x72, 0x65,
0x74, 0x75, 0x72, 0x6e, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x36, 0x0a, 0x09, 0x6e, 0x65, 0x77,
0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f,
0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72,
0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x4c, 0x69, 0x6e, 0x65,
0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03,
0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xa2, 0x01, 0x0a, 0x10, 0x45, 0x64, 0x69, 0x74, 0x4f,
0x72, 0x64, 0x65, 0x72, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73,
0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18,
0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41,
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e,
0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52,
0x0e, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12,
0x25, 0x0a, 0x0e, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x72, 0x69, 0x63,
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e,
0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39, 0x67,
0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f,
0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x2f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}) })
var ( var (
@@ -906,7 +1084,7 @@ func file_order_proto_rawDescGZIP() []byte {
return file_order_proto_rawDescData return file_order_proto_rawDescData
} }
var file_order_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_order_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_order_proto_goTypes = []any{ var file_order_proto_goTypes = []any{
(*OrderLine)(nil), // 0: order_messages.OrderLine (*OrderLine)(nil), // 0: order_messages.OrderLine
(*PlaceOrder)(nil), // 1: order_messages.PlaceOrder (*PlaceOrder)(nil), // 1: order_messages.PlaceOrder
@@ -918,16 +1096,20 @@ var file_order_proto_goTypes = []any{
(*CancelOrder)(nil), // 7: order_messages.CancelOrder (*CancelOrder)(nil), // 7: order_messages.CancelOrder
(*RequestReturn)(nil), // 8: order_messages.RequestReturn (*RequestReturn)(nil), // 8: order_messages.RequestReturn
(*IssueRefund)(nil), // 9: order_messages.IssueRefund (*IssueRefund)(nil), // 9: order_messages.IssueRefund
(*RequestExchange)(nil), // 10: order_messages.RequestExchange
(*EditOrderDetails)(nil), // 11: order_messages.EditOrderDetails
} }
var file_order_proto_depIdxs = []int32{ var file_order_proto_depIdxs = []int32{
0, // 0: order_messages.PlaceOrder.lines:type_name -> order_messages.OrderLine 0, // 0: order_messages.PlaceOrder.lines:type_name -> order_messages.OrderLine
4, // 1: order_messages.CreateFulfillment.lines:type_name -> order_messages.FulfillmentLine 4, // 1: order_messages.CreateFulfillment.lines:type_name -> order_messages.FulfillmentLine
4, // 2: order_messages.RequestReturn.lines:type_name -> order_messages.FulfillmentLine 4, // 2: order_messages.RequestReturn.lines:type_name -> order_messages.FulfillmentLine
3, // [3:3] is the sub-list for method output_type 4, // 3: order_messages.RequestExchange.return_lines:type_name -> order_messages.FulfillmentLine
3, // [3:3] is the sub-list for method input_type 0, // 4: order_messages.RequestExchange.new_lines:type_name -> order_messages.OrderLine
3, // [3:3] is the sub-list for extension type_name 5, // [5:5] is the sub-list for method output_type
3, // [3:3] is the sub-list for extension extendee 5, // [5:5] is the sub-list for method input_type
0, // [0:3] is the sub-list for field type_name 5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
} }
func init() { file_order_proto_init() } func init() { file_order_proto_init() }
@@ -941,7 +1123,7 @@ func file_order_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_order_proto_rawDesc), len(file_order_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_order_proto_rawDesc), len(file_order_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 10, NumMessages: 12,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },
+5
View File
@@ -77,6 +77,9 @@ message LinkOrder {
string status = 3; // order status snapshot string status = 3; // order status snapshot
} }
// AnonymizeProfile wipes all PII from the customer profile.
message AnonymizeProfile {}
message Mutation { message Mutation {
oneof type { oneof type {
SetProfile set_profile = 1; SetProfile set_profile = 1;
@@ -86,5 +89,7 @@ message Mutation {
LinkCart link_cart = 5; LinkCart link_cart = 5;
LinkCheckout link_checkout = 6; LinkCheckout link_checkout = 6;
LinkOrder link_order = 7; LinkOrder link_order = 7;
AnonymizeProfile anonymize_profile = 8;
} }
} }
+254 -192
View File
@@ -2,7 +2,7 @@
// versions: // versions:
// protoc-gen-go v1.36.5 // protoc-gen-go v1.36.5
// protoc v7.35.0 // protoc v7.35.0
// source: proto/profile.proto // source: profile.proto
package profile_messages package profile_messages
@@ -42,7 +42,7 @@ type Address struct {
func (x *Address) Reset() { func (x *Address) Reset() {
*x = Address{} *x = Address{}
mi := &file_proto_profile_proto_msgTypes[0] mi := &file_profile_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -54,7 +54,7 @@ func (x *Address) String() string {
func (*Address) ProtoMessage() {} func (*Address) ProtoMessage() {}
func (x *Address) ProtoReflect() protoreflect.Message { func (x *Address) ProtoReflect() protoreflect.Message {
mi := &file_proto_profile_proto_msgTypes[0] mi := &file_profile_proto_msgTypes[0]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -67,7 +67,7 @@ func (x *Address) ProtoReflect() protoreflect.Message {
// Deprecated: Use Address.ProtoReflect.Descriptor instead. // Deprecated: Use Address.ProtoReflect.Descriptor instead.
func (*Address) Descriptor() ([]byte, []int) { func (*Address) Descriptor() ([]byte, []int) {
return file_proto_profile_proto_rawDescGZIP(), []int{0} return file_profile_proto_rawDescGZIP(), []int{0}
} }
func (x *Address) GetId() uint32 { func (x *Address) GetId() uint32 {
@@ -169,7 +169,7 @@ type SetProfile struct {
func (x *SetProfile) Reset() { func (x *SetProfile) Reset() {
*x = SetProfile{} *x = SetProfile{}
mi := &file_proto_profile_proto_msgTypes[1] mi := &file_profile_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -181,7 +181,7 @@ func (x *SetProfile) String() string {
func (*SetProfile) ProtoMessage() {} func (*SetProfile) ProtoMessage() {}
func (x *SetProfile) ProtoReflect() protoreflect.Message { func (x *SetProfile) ProtoReflect() protoreflect.Message {
mi := &file_proto_profile_proto_msgTypes[1] mi := &file_profile_proto_msgTypes[1]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -194,7 +194,7 @@ func (x *SetProfile) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetProfile.ProtoReflect.Descriptor instead. // Deprecated: Use SetProfile.ProtoReflect.Descriptor instead.
func (*SetProfile) Descriptor() ([]byte, []int) { func (*SetProfile) Descriptor() ([]byte, []int) {
return file_proto_profile_proto_rawDescGZIP(), []int{1} return file_profile_proto_rawDescGZIP(), []int{1}
} }
func (x *SetProfile) GetName() string { func (x *SetProfile) GetName() string {
@@ -249,7 +249,7 @@ type AddAddress struct {
func (x *AddAddress) Reset() { func (x *AddAddress) Reset() {
*x = AddAddress{} *x = AddAddress{}
mi := &file_proto_profile_proto_msgTypes[2] mi := &file_profile_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -261,7 +261,7 @@ func (x *AddAddress) String() string {
func (*AddAddress) ProtoMessage() {} func (*AddAddress) ProtoMessage() {}
func (x *AddAddress) ProtoReflect() protoreflect.Message { func (x *AddAddress) ProtoReflect() protoreflect.Message {
mi := &file_proto_profile_proto_msgTypes[2] mi := &file_profile_proto_msgTypes[2]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -274,7 +274,7 @@ func (x *AddAddress) ProtoReflect() protoreflect.Message {
// Deprecated: Use AddAddress.ProtoReflect.Descriptor instead. // Deprecated: Use AddAddress.ProtoReflect.Descriptor instead.
func (*AddAddress) Descriptor() ([]byte, []int) { func (*AddAddress) Descriptor() ([]byte, []int) {
return file_proto_profile_proto_rawDescGZIP(), []int{2} return file_profile_proto_rawDescGZIP(), []int{2}
} }
func (x *AddAddress) GetAddress() *Address { func (x *AddAddress) GetAddress() *Address {
@@ -305,7 +305,7 @@ type UpdateAddress struct {
func (x *UpdateAddress) Reset() { func (x *UpdateAddress) Reset() {
*x = UpdateAddress{} *x = UpdateAddress{}
mi := &file_proto_profile_proto_msgTypes[3] mi := &file_profile_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -317,7 +317,7 @@ func (x *UpdateAddress) String() string {
func (*UpdateAddress) ProtoMessage() {} func (*UpdateAddress) ProtoMessage() {}
func (x *UpdateAddress) ProtoReflect() protoreflect.Message { func (x *UpdateAddress) ProtoReflect() protoreflect.Message {
mi := &file_proto_profile_proto_msgTypes[3] mi := &file_profile_proto_msgTypes[3]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -330,7 +330,7 @@ func (x *UpdateAddress) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateAddress.ProtoReflect.Descriptor instead. // Deprecated: Use UpdateAddress.ProtoReflect.Descriptor instead.
func (*UpdateAddress) Descriptor() ([]byte, []int) { func (*UpdateAddress) Descriptor() ([]byte, []int) {
return file_proto_profile_proto_rawDescGZIP(), []int{3} return file_profile_proto_rawDescGZIP(), []int{3}
} }
func (x *UpdateAddress) GetId() uint32 { func (x *UpdateAddress) GetId() uint32 {
@@ -427,7 +427,7 @@ type RemoveAddress struct {
func (x *RemoveAddress) Reset() { func (x *RemoveAddress) Reset() {
*x = RemoveAddress{} *x = RemoveAddress{}
mi := &file_proto_profile_proto_msgTypes[4] mi := &file_profile_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -439,7 +439,7 @@ func (x *RemoveAddress) String() string {
func (*RemoveAddress) ProtoMessage() {} func (*RemoveAddress) ProtoMessage() {}
func (x *RemoveAddress) ProtoReflect() protoreflect.Message { func (x *RemoveAddress) ProtoReflect() protoreflect.Message {
mi := &file_proto_profile_proto_msgTypes[4] mi := &file_profile_proto_msgTypes[4]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -452,7 +452,7 @@ func (x *RemoveAddress) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveAddress.ProtoReflect.Descriptor instead. // Deprecated: Use RemoveAddress.ProtoReflect.Descriptor instead.
func (*RemoveAddress) Descriptor() ([]byte, []int) { func (*RemoveAddress) Descriptor() ([]byte, []int) {
return file_proto_profile_proto_rawDescGZIP(), []int{4} return file_profile_proto_rawDescGZIP(), []int{4}
} }
func (x *RemoveAddress) GetId() uint32 { func (x *RemoveAddress) GetId() uint32 {
@@ -473,7 +473,7 @@ type LinkCart struct {
func (x *LinkCart) Reset() { func (x *LinkCart) Reset() {
*x = LinkCart{} *x = LinkCart{}
mi := &file_proto_profile_proto_msgTypes[5] mi := &file_profile_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -485,7 +485,7 @@ func (x *LinkCart) String() string {
func (*LinkCart) ProtoMessage() {} func (*LinkCart) ProtoMessage() {}
func (x *LinkCart) ProtoReflect() protoreflect.Message { func (x *LinkCart) ProtoReflect() protoreflect.Message {
mi := &file_proto_profile_proto_msgTypes[5] mi := &file_profile_proto_msgTypes[5]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -498,7 +498,7 @@ func (x *LinkCart) ProtoReflect() protoreflect.Message {
// Deprecated: Use LinkCart.ProtoReflect.Descriptor instead. // Deprecated: Use LinkCart.ProtoReflect.Descriptor instead.
func (*LinkCart) Descriptor() ([]byte, []int) { func (*LinkCart) Descriptor() ([]byte, []int) {
return file_proto_profile_proto_rawDescGZIP(), []int{5} return file_profile_proto_rawDescGZIP(), []int{5}
} }
func (x *LinkCart) GetCartId() uint64 { func (x *LinkCart) GetCartId() uint64 {
@@ -526,7 +526,7 @@ type LinkCheckout struct {
func (x *LinkCheckout) Reset() { func (x *LinkCheckout) Reset() {
*x = LinkCheckout{} *x = LinkCheckout{}
mi := &file_proto_profile_proto_msgTypes[6] mi := &file_profile_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -538,7 +538,7 @@ func (x *LinkCheckout) String() string {
func (*LinkCheckout) ProtoMessage() {} func (*LinkCheckout) ProtoMessage() {}
func (x *LinkCheckout) ProtoReflect() protoreflect.Message { func (x *LinkCheckout) ProtoReflect() protoreflect.Message {
mi := &file_proto_profile_proto_msgTypes[6] mi := &file_profile_proto_msgTypes[6]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -551,7 +551,7 @@ func (x *LinkCheckout) ProtoReflect() protoreflect.Message {
// Deprecated: Use LinkCheckout.ProtoReflect.Descriptor instead. // Deprecated: Use LinkCheckout.ProtoReflect.Descriptor instead.
func (*LinkCheckout) Descriptor() ([]byte, []int) { func (*LinkCheckout) Descriptor() ([]byte, []int) {
return file_proto_profile_proto_rawDescGZIP(), []int{6} return file_profile_proto_rawDescGZIP(), []int{6}
} }
func (x *LinkCheckout) GetCheckoutId() uint64 { func (x *LinkCheckout) GetCheckoutId() uint64 {
@@ -580,7 +580,7 @@ type LinkOrder struct {
func (x *LinkOrder) Reset() { func (x *LinkOrder) Reset() {
*x = LinkOrder{} *x = LinkOrder{}
mi := &file_proto_profile_proto_msgTypes[7] mi := &file_profile_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -592,7 +592,7 @@ func (x *LinkOrder) String() string {
func (*LinkOrder) ProtoMessage() {} func (*LinkOrder) ProtoMessage() {}
func (x *LinkOrder) ProtoReflect() protoreflect.Message { func (x *LinkOrder) ProtoReflect() protoreflect.Message {
mi := &file_proto_profile_proto_msgTypes[7] mi := &file_profile_proto_msgTypes[7]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -605,7 +605,7 @@ func (x *LinkOrder) ProtoReflect() protoreflect.Message {
// Deprecated: Use LinkOrder.ProtoReflect.Descriptor instead. // Deprecated: Use LinkOrder.ProtoReflect.Descriptor instead.
func (*LinkOrder) Descriptor() ([]byte, []int) { func (*LinkOrder) Descriptor() ([]byte, []int) {
return file_proto_profile_proto_rawDescGZIP(), []int{7} return file_profile_proto_rawDescGZIP(), []int{7}
} }
func (x *LinkOrder) GetOrderReference() string { func (x *LinkOrder) GetOrderReference() string {
@@ -629,6 +629,43 @@ func (x *LinkOrder) GetStatus() string {
return "" return ""
} }
// AnonymizeProfile wipes all PII from the customer profile.
type AnonymizeProfile struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AnonymizeProfile) Reset() {
*x = AnonymizeProfile{}
mi := &file_profile_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AnonymizeProfile) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AnonymizeProfile) ProtoMessage() {}
func (x *AnonymizeProfile) ProtoReflect() protoreflect.Message {
mi := &file_profile_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AnonymizeProfile.ProtoReflect.Descriptor instead.
func (*AnonymizeProfile) Descriptor() ([]byte, []int) {
return file_profile_proto_rawDescGZIP(), []int{8}
}
type Mutation struct { type Mutation struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Type: // Types that are valid to be assigned to Type:
@@ -640,6 +677,7 @@ type Mutation struct {
// *Mutation_LinkCart // *Mutation_LinkCart
// *Mutation_LinkCheckout // *Mutation_LinkCheckout
// *Mutation_LinkOrder // *Mutation_LinkOrder
// *Mutation_AnonymizeProfile
Type isMutation_Type `protobuf_oneof:"type"` Type isMutation_Type `protobuf_oneof:"type"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@@ -647,7 +685,7 @@ type Mutation struct {
func (x *Mutation) Reset() { func (x *Mutation) Reset() {
*x = Mutation{} *x = Mutation{}
mi := &file_proto_profile_proto_msgTypes[8] mi := &file_profile_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -659,7 +697,7 @@ func (x *Mutation) String() string {
func (*Mutation) ProtoMessage() {} func (*Mutation) ProtoMessage() {}
func (x *Mutation) ProtoReflect() protoreflect.Message { func (x *Mutation) ProtoReflect() protoreflect.Message {
mi := &file_proto_profile_proto_msgTypes[8] mi := &file_profile_proto_msgTypes[9]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -672,7 +710,7 @@ func (x *Mutation) ProtoReflect() protoreflect.Message {
// Deprecated: Use Mutation.ProtoReflect.Descriptor instead. // Deprecated: Use Mutation.ProtoReflect.Descriptor instead.
func (*Mutation) Descriptor() ([]byte, []int) { func (*Mutation) Descriptor() ([]byte, []int) {
return file_proto_profile_proto_rawDescGZIP(), []int{8} return file_profile_proto_rawDescGZIP(), []int{9}
} }
func (x *Mutation) GetType() isMutation_Type { func (x *Mutation) GetType() isMutation_Type {
@@ -745,6 +783,15 @@ func (x *Mutation) GetLinkOrder() *LinkOrder {
return nil return nil
} }
func (x *Mutation) GetAnonymizeProfile() *AnonymizeProfile {
if x != nil {
if x, ok := x.Type.(*Mutation_AnonymizeProfile); ok {
return x.AnonymizeProfile
}
}
return nil
}
type isMutation_Type interface { type isMutation_Type interface {
isMutation_Type() isMutation_Type()
} }
@@ -777,6 +824,10 @@ type Mutation_LinkOrder struct {
LinkOrder *LinkOrder `protobuf:"bytes,7,opt,name=link_order,json=linkOrder,proto3,oneof"` LinkOrder *LinkOrder `protobuf:"bytes,7,opt,name=link_order,json=linkOrder,proto3,oneof"`
} }
type Mutation_AnonymizeProfile struct {
AnonymizeProfile *AnonymizeProfile `protobuf:"bytes,8,opt,name=anonymize_profile,json=anonymizeProfile,proto3,oneof"`
}
func (*Mutation_SetProfile) isMutation_Type() {} func (*Mutation_SetProfile) isMutation_Type() {}
func (*Mutation_AddAddress) isMutation_Type() {} func (*Mutation_AddAddress) isMutation_Type() {}
@@ -791,159 +842,167 @@ func (*Mutation_LinkCheckout) isMutation_Type() {}
func (*Mutation_LinkOrder) isMutation_Type() {} func (*Mutation_LinkOrder) isMutation_Type() {}
var File_proto_profile_proto protoreflect.FileDescriptor func (*Mutation_AnonymizeProfile) isMutation_Type() {}
var file_proto_profile_proto_rawDesc = string([]byte{ var File_profile_proto protoreflect.FileDescriptor
0x0a, 0x13, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, var file_profile_proto_rawDesc = string([]byte{
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xfe, 0x02, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72, 0x0a, 0x0d, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12,
0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x73, 0x22, 0xfe, 0x02, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a,
0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a,
0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61,
0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x62, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18,
0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12,
0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x22, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18,
0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69,
0x00, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, 0x6e, 0x65, 0x31, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69,
0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x61, 0x64, 0x64,
0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04,
0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79,
0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x12, 0x18, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20,
0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x88, 0x01, 0x01, 0x12, 0x2c, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x72, 0x79, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28,
0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x09, 0x48, 0x01, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x2c, 0x0a,
0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69,
0x67, 0x12, 0x2a, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61,
0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x2a, 0x0a, 0x10, 0x69,
0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x42, 0x0f, 0x0a,
0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x08,
0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x22, 0x85, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x74,
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01,
0x12, 0x19, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48,
0x01, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70,
0x68, 0x6f, 0x6e, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x05, 0x70, 0x68,
0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61,
0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67,
0x75, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65,
0x6e, 0x63, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x08, 0x63, 0x75, 0x72,
0x72, 0x65, 0x6e, 0x63, 0x79, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x74,
0x61, 0x72, 0x55, 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x09, 0x61,
0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f,
0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08,
0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x6e,
0x67, 0x75, 0x61, 0x67, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
0x63, 0x79, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c,
0x22, 0x41, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x33,
0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x19, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72,
0x65, 0x73, 0x73, 0x22, 0xab, 0x04, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64,
0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x88, 0x01, 0x01,
0x12, 0x1f, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01,
0x28, 0x09, 0x48, 0x01, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01,
0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65,
0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65,
0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64,
0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
0x48, 0x03, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32,
0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28,
0x09, 0x48, 0x04, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05,
0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x05, 0x73,
0x74, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08,
0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d,
0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48,
0x07, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a,
0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x08, 0x52, 0x05,
0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65,
0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20,
0x01, 0x28, 0x08, 0x48, 0x09, 0x52, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x69,
0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18,
0x0c, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0a, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72,
0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f,
0x61, 0x6d, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x6e, 0x65, 0x22, 0x85, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c,
0x69, 0x6e, 0x65, 0x31, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48,
0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x08, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x65, 0x6d,
0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x65, 0x6d, 0x61,
0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x69, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x03,
0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01,
0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x42, 0x13, 0x0a, 0x11, 0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01,
0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x28, 0x09, 0x48, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x88, 0x01,
0x67, 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x05, 0x20,
0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x88,
0x69, 0x64, 0x22, 0x38, 0x0a, 0x08, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x16, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x18,
0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55,
0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x46, 0x0a, 0x0c, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f,
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x6e, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x42,
0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x0b, 0x0a, 0x09, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x0c, 0x0a, 0x0a,
0x52, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x22, 0x41, 0x0a, 0x0a, 0x41, 0x64,
0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x33, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72,
0x72, 0x74, 0x49, 0x64, 0x22, 0x63, 0x0a, 0x09, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x66,
0x72, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64,
0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x72, 0x65, 0x73, 0x73, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0xab, 0x04,
0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12,
0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12,
0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x19, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xe8, 0x03, 0x0a, 0x08, 0x4d, 0x75, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x66, 0x75,
0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3f, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x72, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x08,
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61,
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28,
0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x74, 0x09, 0x48, 0x02, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65,
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x3f, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x61, 0x31, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c,
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x0c, 0x61, 0x64,
0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a,
0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x04, 0x63,
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x75, 0x70, 0x64, 0x61, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18,
0x74, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x88, 0x01,
0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06,
0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
0x73, 0x48, 0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48, 0x07, 0x52, 0x07, 0x63, 0x6f, 0x75,
0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65,
0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x08, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88,
0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53,
0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x48, 0x09, 0x52,
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69,
0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75,
0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48,
0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x0a, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c,
0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x45, 0x0a, 0x0d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c,
0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x0f, 0x0a,
0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x42, 0x0f,
0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42,
0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61,
0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65,
0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68,
0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66,
0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x22, 0x1f, 0x0a, 0x0d, 0x52,
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0x38, 0x0a, 0x08,
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74,
0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64,
0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x46, 0x0a, 0x0c, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68,
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f,
0x75, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x63, 0x68, 0x65, 0x63,
0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64,
0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x22, 0x63,
0x0a, 0x09, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x6f,
0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65,
0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20,
0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73,
0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61,
0x74, 0x75, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65,
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x22, 0xbb, 0x04, 0x0a, 0x08, 0x4d, 0x75, 0x74, 0x61,
0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3f, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x66,
0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x66,
0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x74, 0x50, 0x72,
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x3f, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x61, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f,
0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64,
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x64, 0x41,
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65,
0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f,
0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x48, 0x00, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48,
0x52, 0x0c, 0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x3c, 0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
0x0a, 0x0a, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65,
0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69,
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x48, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f,
0x00, 0x52, 0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x06, 0x0a, 0x04, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d,
0x74, 0x79, 0x70, 0x65, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09, 0x6c, 0x69,
0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
0x66, 0x69, 0x6c, 0x65, 0x3b, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x69, 0x6e,
0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x45, 0x0a, 0x0d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x68,
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70,
0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x48, 0x00, 0x52, 0x0c,
0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x3c, 0x0a, 0x0a,
0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x48, 0x00, 0x52,
0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x51, 0x0a, 0x11, 0x61, 0x6e,
0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18,
0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69,
0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x10, 0x61, 0x6e, 0x6f,
0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x42, 0x06, 0x0a,
0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e,
0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72,
0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72,
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x3b, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}) })
var ( var (
file_proto_profile_proto_rawDescOnce sync.Once file_profile_proto_rawDescOnce sync.Once
file_proto_profile_proto_rawDescData []byte file_profile_proto_rawDescData []byte
) )
func file_proto_profile_proto_rawDescGZIP() []byte { func file_profile_proto_rawDescGZIP() []byte {
file_proto_profile_proto_rawDescOnce.Do(func() { file_profile_proto_rawDescOnce.Do(func() {
file_proto_profile_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_profile_proto_rawDesc), len(file_proto_profile_proto_rawDesc))) file_profile_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_profile_proto_rawDesc), len(file_profile_proto_rawDesc)))
}) })
return file_proto_profile_proto_rawDescData return file_profile_proto_rawDescData
} }
var file_proto_profile_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_profile_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_proto_profile_proto_goTypes = []any{ var file_profile_proto_goTypes = []any{
(*Address)(nil), // 0: profile_messages.Address (*Address)(nil), // 0: profile_messages.Address
(*SetProfile)(nil), // 1: profile_messages.SetProfile (*SetProfile)(nil), // 1: profile_messages.SetProfile
(*AddAddress)(nil), // 2: profile_messages.AddAddress (*AddAddress)(nil), // 2: profile_messages.AddAddress
@@ -952,9 +1011,10 @@ var file_proto_profile_proto_goTypes = []any{
(*LinkCart)(nil), // 5: profile_messages.LinkCart (*LinkCart)(nil), // 5: profile_messages.LinkCart
(*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout (*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout
(*LinkOrder)(nil), // 7: profile_messages.LinkOrder (*LinkOrder)(nil), // 7: profile_messages.LinkOrder
(*Mutation)(nil), // 8: profile_messages.Mutation (*AnonymizeProfile)(nil), // 8: profile_messages.AnonymizeProfile
(*Mutation)(nil), // 9: profile_messages.Mutation
} }
var file_proto_profile_proto_depIdxs = []int32{ var file_profile_proto_depIdxs = []int32{
0, // 0: profile_messages.AddAddress.address:type_name -> profile_messages.Address 0, // 0: profile_messages.AddAddress.address:type_name -> profile_messages.Address
1, // 1: profile_messages.Mutation.set_profile:type_name -> profile_messages.SetProfile 1, // 1: profile_messages.Mutation.set_profile:type_name -> profile_messages.SetProfile
2, // 2: profile_messages.Mutation.add_address:type_name -> profile_messages.AddAddress 2, // 2: profile_messages.Mutation.add_address:type_name -> profile_messages.AddAddress
@@ -963,22 +1023,23 @@ var file_proto_profile_proto_depIdxs = []int32{
5, // 5: profile_messages.Mutation.link_cart:type_name -> profile_messages.LinkCart 5, // 5: profile_messages.Mutation.link_cart:type_name -> profile_messages.LinkCart
6, // 6: profile_messages.Mutation.link_checkout:type_name -> profile_messages.LinkCheckout 6, // 6: profile_messages.Mutation.link_checkout:type_name -> profile_messages.LinkCheckout
7, // 7: profile_messages.Mutation.link_order:type_name -> profile_messages.LinkOrder 7, // 7: profile_messages.Mutation.link_order:type_name -> profile_messages.LinkOrder
8, // [8:8] is the sub-list for method output_type 8, // 8: profile_messages.Mutation.anonymize_profile:type_name -> profile_messages.AnonymizeProfile
8, // [8:8] is the sub-list for method input_type 9, // [9:9] is the sub-list for method output_type
8, // [8:8] is the sub-list for extension type_name 9, // [9:9] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension extendee 9, // [9:9] is the sub-list for extension type_name
0, // [0:8] is the sub-list for field type_name 9, // [9:9] is the sub-list for extension extendee
0, // [0:9] is the sub-list for field type_name
} }
func init() { file_proto_profile_proto_init() } func init() { file_profile_proto_init() }
func file_proto_profile_proto_init() { func file_profile_proto_init() {
if File_proto_profile_proto != nil { if File_profile_proto != nil {
return return
} }
file_proto_profile_proto_msgTypes[0].OneofWrappers = []any{} file_profile_proto_msgTypes[0].OneofWrappers = []any{}
file_proto_profile_proto_msgTypes[1].OneofWrappers = []any{} file_profile_proto_msgTypes[1].OneofWrappers = []any{}
file_proto_profile_proto_msgTypes[3].OneofWrappers = []any{} file_profile_proto_msgTypes[3].OneofWrappers = []any{}
file_proto_profile_proto_msgTypes[8].OneofWrappers = []any{ file_profile_proto_msgTypes[9].OneofWrappers = []any{
(*Mutation_SetProfile)(nil), (*Mutation_SetProfile)(nil),
(*Mutation_AddAddress)(nil), (*Mutation_AddAddress)(nil),
(*Mutation_UpdateAddress)(nil), (*Mutation_UpdateAddress)(nil),
@@ -986,22 +1047,23 @@ func file_proto_profile_proto_init() {
(*Mutation_LinkCart)(nil), (*Mutation_LinkCart)(nil),
(*Mutation_LinkCheckout)(nil), (*Mutation_LinkCheckout)(nil),
(*Mutation_LinkOrder)(nil), (*Mutation_LinkOrder)(nil),
(*Mutation_AnonymizeProfile)(nil),
} }
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_profile_proto_rawDesc), len(file_proto_profile_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_profile_proto_rawDesc), len(file_profile_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 9, NumMessages: 10,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },
GoTypes: file_proto_profile_proto_goTypes, GoTypes: file_profile_proto_goTypes,
DependencyIndexes: file_proto_profile_proto_depIdxs, DependencyIndexes: file_profile_proto_depIdxs,
MessageInfos: file_proto_profile_proto_msgTypes, MessageInfos: file_profile_proto_msgTypes,
}.Build() }.Build()
File_proto_profile_proto = out.File File_profile_proto = out.File
file_proto_profile_proto_goTypes = nil file_profile_proto_goTypes = nil
file_proto_profile_proto_depIdxs = nil file_profile_proto_depIdxs = nil
} }