diff --git a/Makefile b/Makefile index 019caaf..440abfc 100644 --- a/Makefile +++ b/Makefile @@ -19,11 +19,12 @@ MODULE_PATH := git.k6n.net/mats/go-cart-actor 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 CONTROL_PROTO_DIR := $(PROTO_DIR)/control CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout ORDER_PROTO_DIR := $(PROTO_DIR)/order +PROFILE_PROTO_DIR := $(PROTO_DIR)/profile # Allow override: make PROTOC=/path/to/protoc PROTOC ?= protoc @@ -88,6 +89,9 @@ protogen: check_tools --go_out=./proto/order --go_opt=paths=source_relative \ --go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \ $(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)" 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)/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)/order/*_grpc.pb.go $(PROTO_DIR)/order/*.pb.go + @rm -f $(PROTO_DIR)/profile/*.pb.go @echo "$(GREEN)Clean complete.$(RESET)" verify_proto: diff --git a/cmd/cart/k8s-host-discovery.go b/cmd/cart/k8s-host-discovery.go index cfba8d0..16c9e3c 100644 --- a/cmd/cart/k8s-host-discovery.go +++ b/cmd/cart/k8s-host-discovery.go @@ -24,7 +24,9 @@ func GetDiscovery() discovery.Discovery { log.Fatalf("Error creating client: %v\n", err) } 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", TimeoutSeconds: &timeout, }) diff --git a/cmd/cart/main.go b/cmd/cart/main.go index c5d1d36..74fdd6e 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -12,19 +12,21 @@ import ( "strings" "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/cart" 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/internal/ucp" "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-redis-inventory/pkg/inventory" "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/promauto" "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" ) @@ -51,13 +53,6 @@ var redisAddress = os.Getenv("REDIS_ADDRESS") var redisPassword = os.Getenv("REDIS_PASSWORD") // 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 // address (":8080", "0.0.0.0:8080") and returns a value usable by http.Server.Addr. func normalizeListenAddr(v string) string { @@ -67,6 +62,14 @@ func normalizeListenAddr(v string) string { 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 // the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable. func loadUCPCartSigner() *ucp.SigningConfig { @@ -144,32 +147,76 @@ func main() { //inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]() - // rdb := redis.NewClient(&redis.Options{ - // Addr: redisAddress, - // Password: redisPassword, - // DB: 0, - // }) - // inventoryService, err := inventory.NewRedisInventoryService(rdb) - // if err != nil { - // log.Fatalf("Error creating inventory service: %v\n", err) - // } + rdb := redis.NewClient(&redis.Options{ + Addr: redisAddress, + Password: redisPassword, + DB: 0, + }) + inventoryService, err := inventory.NewRedisInventoryService(rdb) + if err != nil { + log.Fatalf("Error creating inventory service: %v\n", err) + } + _ = inventoryService - // inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb) - // if err != nil { - // log.Fatalf("Error creating inventory reservation service: %v\n", err) - // } + inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb) + if err != nil { + log.Fatalf("Error creating inventory reservation service: %v\n", err) + } - reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) + reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService)) reg.RegisterProcessor( actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error { _, span := tracer.Start(ctx, "Totals and promotions") defer span.End() + + // Clear bypass flags first + for _, v := range g.Vouchers { + if v != nil { + v.BypassedByPromotions = false + } + } + 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())) 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 // every effect — both applied discounts and pending "spend X more for ..." // nudges with their progress — in g.AppliedPromotions. @@ -287,7 +334,7 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - otelShutdown, err := setupOTelSDK(ctx) + otelShutdown, err := telemetry.SetupOTelSDK(ctx) if err != nil { log.Fatalf("Unable to start otel %v", err) } diff --git a/cmd/cart/otel.go b/cmd/cart/otel.go deleted file mode 100644 index b1891bc..0000000 --- a/cmd/cart/otel.go +++ /dev/null @@ -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 -} diff --git a/cmd/cart/pool-server.go b/cmd/cart/pool-server.go index 75a59b6..2a51661 100644 --- a/cmd/cart/pool-server.go +++ b/cmd/cart/pool-server.go @@ -17,7 +17,6 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/voucher" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" @@ -399,7 +398,6 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request span.SetAttributes(attribute.String("cartid", cartId.String())) hostAttr := attribute.String("other host", ownerHost.Name()) span.SetAttributes(hostAttr) - logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name()) proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr)) 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 ( tracer = otel.Tracer(name) meter = otel.Meter(name) - logger = otelslog.NewLogger(name) proxyCalls metric.Int64Counter ) diff --git a/cmd/checkout/adyen-handlers.go b/cmd/checkout/adyen-handlers.go index 96d7b36..b0e1d2e 100644 --- a/cmd/checkout/adyen-handlers.go +++ b/cmd/checkout/adyen-handlers.go @@ -12,6 +12,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/cart" 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" "github.com/adyen/adyen-go-api-library/v21/src/common" "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 // order now (mirrors KlarnaPushHandler). Idempotent on // "checkout-{checkoutId}", so a retried CAPTURE won't duplicate. - if isSuccess && s.orderClient != nil { + if isSuccess { s.createAdyenOrder(r.Context(), *checkoutId, item) } @@ -231,12 +232,21 @@ func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId ca 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 rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil { log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr) } 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{ Id: grain.Id.String(), Status: "success", diff --git a/cmd/checkout/checkout_builder.go b/cmd/checkout/checkout_builder.go index 9a10ce7..240e145 100644 --- a/cmd/checkout/checkout_builder.go +++ b/cmd/checkout/checkout_builder.go @@ -64,7 +64,9 @@ type CheckoutMeta struct { // // If you later need to support different tax rates per line, you can extend // CartItem / Delivery to expose that data and propagate it here. -func BuildCheckoutOrderPayload(grain *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 { 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.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 { - 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 } //total.Add(d.Price) @@ -120,11 +142,11 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta Reference: d.Provider, Name: "Delivery", Quantity: 1, - UnitPrice: int(d.Price.IncVat), - TaxRate: 2500, + UnitPrice: int(unitPrice), + TaxRate: defaultKlarnaRate, QuantityUnit: "st", - TotalAmount: int(d.Price.IncVat), - TotalTaxAmount: int(d.Price.TotalVat()), + TotalAmount: int(unitPrice), + TotalTaxAmount: int(taxAmount), }) } @@ -154,6 +176,24 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta 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 { host := getOriginalHost(r) 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 { return nil, fmt.Errorf("nil grain") } @@ -204,20 +244,40 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta 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.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 { - 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 } lineItems = append(lineItems, adyenCheckout.LineItem{ Quantity: common.PtrInt64(1), - AmountIncludingTax: common.PtrInt64(d.Price.IncVat), + AmountIncludingTax: common.PtrInt64(amountIncTax), Description: common.PtrString("Delivery"), - AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()), - TaxPercentage: common.PtrInt64(25), + AmountExcludingTax: common.PtrInt64(amountExTax), + TaxPercentage: common.PtrInt64(defaultAdyenRate), }) } diff --git a/cmd/checkout/k8s-host-discovery.go b/cmd/checkout/k8s-host-discovery.go index fa31da0..9844b76 100644 --- a/cmd/checkout/k8s-host-discovery.go +++ b/cmd/checkout/k8s-host-discovery.go @@ -24,7 +24,9 @@ func GetDiscovery() discovery.Discovery { log.Fatalf("Error creating client: %v\n", err) } 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", TimeoutSeconds: &timeout, }) diff --git a/cmd/checkout/klarna-handlers.go b/cmd/checkout/klarna-handlers.go index ac7a174..f2ddbb5 100644 --- a/cmd/checkout/klarna-handlers.go +++ b/cmd/checkout/klarna-handlers.go @@ -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", "err", err, "checkoutId", grain.Id.String()) 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{ Id: grain.Id.String(), @@ -209,22 +221,12 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re CompletedAt: timestamppb.Now(), }) - // ── Create the event-sourced order grain (dual-write with AMQP) ──── - if s.orderClient != nil { - if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil { - // Non-fatal: the checkout grain is updated, the order can be - // created on retry (idempotency key guards against duplicates). - logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push", - "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) - } + // ── Create the event-sourced order grain (HTTP with AMQP fallback) ──── + if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil { + // Non-fatal: the checkout grain is updated, the order can be + // created on retry (idempotency key guards against duplicates). + logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push", + "err", orderErr, "checkoutId", grain.Id.String()) } err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId) @@ -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 { if item.StoreId == nil || *item.StoreId == "" { @@ -325,10 +275,20 @@ func getLocationId(item *cart.CartItem) inventory.LocationID { 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 { var requests []inventory.ReserveRequest for _, item := range items { - if item == nil { + if item == nil || !shouldTrackInventory(item) { continue } requests = append(requests, inventory.ReserveRequest{ diff --git a/cmd/checkout/main.go b/cmd/checkout/main.go index 6eba414..1c68b8c 100644 --- a/cmd/checkout/main.go +++ b/cmd/checkout/main.go @@ -12,14 +12,16 @@ import ( "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/cart" "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/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/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/promauto" + amqp "github.com/rabbitmq/amqp091-go" "github.com/redis/go-redis/v9" "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 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 // the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable. func loadUCPCheckoutSigner() *ucp.SigningConfig { @@ -90,12 +113,17 @@ func main() { 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") if checkoutDir == "" { checkoutDir = "data" } diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg) - + var syncedServer *CheckoutPoolServer poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{ MutationRegistry: reg, Storage: diskStorage, @@ -113,6 +141,11 @@ func main() { return ret, nil }, 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 }, SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) { @@ -153,8 +186,10 @@ func main() { 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.reservationService = reservationService + syncedServer.taxProvider = selectTaxProvider() mux := http.NewServeMux() debugMux := http.NewServeMux() @@ -174,7 +209,7 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - otelShutdown, err := setupOTelSDK(ctx) + otelShutdown, err := telemetry.SetupOTelSDK(ctx) if err != nil { log.Fatalf("Unable to start otel %v", err) } diff --git a/cmd/checkout/order_create.go b/cmd/checkout/order_create.go index 05ca4f5..a31c0d7 100644 --- a/cmd/checkout/order_create.go +++ b/cmd/checkout/order_create.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "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 // order payload is built the same way no matter who settled the payment. 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)) for _, it := range grain.CartState.Items { if it == nil { @@ -38,11 +49,19 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { Name: it.Meta.Name, Quantity: int32(it.Quantity), 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 { - if d == nil || d.Price.IncVat <= 0 { + if d == nil { + continue + } + unitPrice := d.Price.IncVat + if hasFreeShipping { + unitPrice = 0 + } + if unitPrice <= 0 && !hasFreeShipping { continue } lines = append(lines, OrderLine{ @@ -50,10 +69,35 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { Sku: d.Provider, Name: "Delivery", Quantity: 1, - UnitPrice: d.Price.IncVat, - TaxRate: 2500, + UnitPrice: unitPrice, + 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 } @@ -64,8 +108,10 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { // // It is idempotent: the order service dedupes on "checkout-{checkoutId}", so // repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the -// existing order rather than creating a duplicate. Callers should treat a -// non-nil error as non-fatal and retry on the next callback. +// existing order rather than creating a duplicate. +// +// 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) { if grain.CartState == nil { return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id) @@ -96,15 +142,41 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, req.Payment.Reference = pay.Reference req.Payment.Amount = pay.Amount - result, err := s.orderClient.CreateOrder(ctx, req, "") - if err != nil { - return nil, err + var createErr error + if s.orderClient != nil { + result, err := s.orderClient.CreateOrder(ctx, req, "") + if err == nil { + _ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{ + OrderId: result.OrderId, + Status: "completed", + CreatedAt: timestamppb.Now(), + }) + return result, nil + } + createErr = err + logger.WarnContext(ctx, "from-checkout HTTP call failed; falling back to AMQP", "err", err, "checkoutId", grain.Id.String()) } - _ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{ - OrderId: result.OrderId, - Status: "completed", - CreatedAt: timestamppb.Now(), - }) - return result, nil + // 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") } diff --git a/cmd/checkout/otel.go b/cmd/checkout/otel.go deleted file mode 100644 index b1891bc..0000000 --- a/cmd/checkout/otel.go +++ /dev/null @@ -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 -} diff --git a/cmd/checkout/pool-server.go b/cmd/checkout/pool-server.go index 62f4f88..72c9624 100644 --- a/cmd/checkout/pool-server.go +++ b/cmd/checkout/pool-server.go @@ -51,7 +51,9 @@ type CheckoutPoolServer struct { cartClient *CartClient orderClient *OrderClient 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 { @@ -207,6 +209,10 @@ func (s *CheckoutPoolServer) CancelPaymentHandler(w http.ResponseWriter, r *http 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) } @@ -295,7 +301,7 @@ func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *chec meta := GetCheckoutMetaFromRequest(r) - payload, _, err := BuildCheckoutOrderPayload(grain, meta) + payload, _, err := BuildCheckoutOrderPayload(grain, meta, s.taxProvider) if err != nil { return nil, err } @@ -431,7 +437,7 @@ func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http. switch payload.Provider { case "adyen": meta := GetCheckoutMetaFromRequest(r) - sessionData, err := BuildAdyenCheckoutSession(grain, meta) + sessionData, err := BuildAdyenCheckoutSession(grain, meta, s.taxProvider) if err != nil { logger.Error("unable to build adyen session", "error", 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/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler))) handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler))) - // 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/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler))) - // handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler))) + 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/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler))) + 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/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) } + +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) + } + } +} diff --git a/cmd/inventory/stockhandler.go b/cmd/inventory/stockhandler.go index 5283d98..1c069bb 100644 --- a/cmd/inventory/stockhandler.go +++ b/cmd/inventory/stockhandler.go @@ -25,10 +25,9 @@ func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) { if !ok { centralStock = 0 } - sku, ok := item.GetStringFieldValue("sku") 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 } else { s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock)) diff --git a/cmd/order/amqp.go b/cmd/order/amqp.go index 4205772..5abb358 100644 --- a/cmd/order/amqp.go +++ b/cmd/order/amqp.go @@ -3,13 +3,9 @@ package main import ( "context" "encoding/json" - "hash/fnv" - "log/slog" "sync" "time" - messages "git.k6n.net/mats/go-cart-actor/proto/order" - amqp "github.com/rabbitmq/amqp091-go" ) @@ -73,135 +69,56 @@ func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) erro return err } -// This ingester lets the order service supersede the legacy go-order-manager: -// it consumes the same "order-queue" the Klarna/Adyen checkout publishes to and -// records each completed order as an event-sourced grain. Those orders are -// already paid by the external processor, so we record payment with provider -// "legacy" (place -> authorize -> capture) rather than charging again. - -// 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 { +// ingestOrderFromQueue records one order from the queue. It uses the exact same +// idempotent business logic as the HTTP endpoint. +func ingestOrderFromQueue(ctx context.Context, s *server, body []byte) error { + var req fromCheckoutReq + if err := json.Unmarshal(body, &req); err != nil { return err } - if lo.ID == "" || len(lo.OrderLines) == 0 { + if len(req.Lines) == 0 { return nil // nothing actionable } - id := legacyOrderID(lo.ID) - ms := time.Now().UnixMilli() - po := &messages.PlaceOrder{ - OrderReference: lo.ID, - Currency: lo.PurchaseCurrency, - Locale: lo.Locale, - Country: lo.PurchaseCountry, - TotalAmount: lo.OrderAmount, - TotalTax: lo.OrderTaxAmount, - PlacedAtMs: ms, + _, _, _, _, runErr, err := s.createOrderFromCheckoutInternal(ctx, &req) + if err != nil { + return err } - if lo.Customer != nil { - po.CustomerEmail = lo.Customer.Email + if runErr != nil { + 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 } -// startLegacyIngest connects to AMQP and consumes "order-queue" until ctx is -// done. Best-effort: a connection failure is logged and ingestion is skipped -// (the HTTP checkout path keeps working regardless). -func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier, logger *slog.Logger) { +// startOrderIngest connects to AMQP and consumes "order-queue" until ctx is +// done. +func startOrderIngest(ctx context.Context, amqpURL string, s *server) { conn, err := amqp.Dial(amqpURL) 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 } ch, err := conn.Channel() 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() return } q, err := ch.QueueDeclare("order-queue", false, false, false, false, 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() _ = conn.Close() return } msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, 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() _ = conn.Close() return } - logger.Info("ingesting legacy orders from order-queue") + s.logger.Info("ingesting orders from order-queue") go func() { defer conn.Close() defer ch.Close() @@ -211,11 +128,11 @@ func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier, return case d, ok := <-msgs: if !ok { - logger.Warn("legacy order ingest: channel closed") + s.logger.Warn("order ingest: channel closed") return } - if err := ingestLegacyOrder(ctx, app, d.Body); err != nil { - logger.Error("legacy order ingest failed", "err", err) + if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil { + s.logger.Error("order queue ingest failed", "err", err) } } } diff --git a/cmd/order/amqp_test.go b/cmd/order/amqp_test.go index 2693bce..1e8897c 100644 --- a/cmd/order/amqp_test.go +++ b/cmd/order/amqp_test.go @@ -3,14 +3,19 @@ package main import ( "context" "fmt" + "io" + "log/slog" + "path/filepath" "testing" "time" "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" ) -func testApplier(t *testing.T) *orderedApplier { +func testServer(t *testing.T) *server { t.Helper() reg := actor.NewMutationRegistry() order.RegisterMutations(reg) @@ -32,23 +37,56 @@ func testApplier(t *testing.T) *orderedApplier { t.Fatal(err) } 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) + } + + idem, err := idempotency.Open(filepath.Join(t.TempDir(), "idem.log")) + 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 TestIngestLegacyOrder(t *testing.T) { - app := testApplier(t) +func TestIngestOrderFromQueue(t *testing.T) { + s := testServer(t) ctx := context.Background() body := []byte(`{ - "order_id":"K-1","purchase_currency":"SEK","purchase_country":"se","locale":"sv-SE", - "order_amount":15000,"order_tax_amount":3000, - "order_lines":[{"reference":"l1","name":"Widget","quantity":1,"unit_price":15000,"tax_rate":25,"total_amount":15000,"total_tax_amount":3000}] + "checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1", + "currency":"SEK","country":"SE","customerEmail":"customer@example.com", + "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) } - 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 { t.Fatal(err) } @@ -60,10 +98,10 @@ func TestIngestLegacyOrder(t *testing.T) { } // 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) } - g2, _ := app.Get(ctx, id) + g2, _ := s.applier.Get(ctx, id) if g2.CapturedAmount != 15000 { t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount) } diff --git a/cmd/order/handlers.go b/cmd/order/handlers.go index f14377a..3136384 100644 --- a/cmd/order/handlers.go +++ b/cmd/order/handlers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "os" "time" "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) { - id, _, ok := s.loadOrder(w, r) + id, g, ok := s.loadOrder(w, r) if !ok { return } @@ -110,11 +111,72 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) { return } 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{ Id: "f_" + fid.String(), - Carrier: req.Carrier, - TrackingNumber: req.TrackingNumber, - TrackingUri: req.TrackingURI, + Carrier: carrier, + TrackingNumber: trackingNumber, + TrackingUri: trackingURI, AtMs: nowMs(), } 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) } +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) { id, _, ok := s.loadOrder(w, r) if !ok { diff --git a/cmd/order/handlers_checkout.go b/cmd/order/handlers_checkout.go index cd86d9f..5f5f4e0 100644 --- a/cmd/order/handlers_checkout.go +++ b/cmd/order/handlers_checkout.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "net/http" @@ -18,7 +19,7 @@ import ( type fromCheckoutReq struct { CheckoutId string `json:"checkoutId"` IdempotencyKey string `json:"idempotencyKey"` - Flow string `json:"flow,omitempty"` + Flow string `json:"flow,omitempty"` CartId string `json:"cartId"` Currency string `json:"currency"` @@ -55,101 +56,95 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) { return } - // ── Idempotency check ───────────────────────────────────────────── - // 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. + ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + + if exists { + writeJSON(w, http.StatusConflict, map[string]any{ + "orderId": order.OrderId(ordID).String(), + "order": grain, + "existing": true, + }) + 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 { - // The order already exists — return it. - g, err := s.applier.Get(r.Context(), existingID) + g, err := s.applier.Get(ctx, existingID) if err != nil { - writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err)) - return + return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err) } - writeJSON(w, http.StatusConflict, map[string]any{ - "orderId": order.OrderId(existingID).String(), - "order": g, - "existing": true, - }) - return + return existingID, nil, g, true, nil, nil } } - // ── Create the order grain ──────────────────────────────────────── id, err := order.NewOrderId() if err != nil { - writeErr(w, http.StatusInternalServerError, fmt.Errorf("new order id: %w", err)) - return + return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err) } ordID := uint64(id) - // Build PlaceOrder from the same lineReq format the direct checkout uses. - po := buildFromCheckoutPlaceOrder(id, &req) - - // Build a passthrough provider for the externally-settled payment. + po := buildFromCheckoutPlaceOrder(id, req) 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 if flowName == "" { flowName = s.defaultFlow } def, ok := s.flows.get(flowName) if !ok { - writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName)) - return + return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName) } - // 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() flow.RegisterBuiltinHooks(freg) order.RegisterFlowActions(freg, s.applier, provider) order.RegisterEmitHook(freg, nil) engine := flow.NewEngine(freg, s.logger) - st := flow.NewState(ordID, s.logger) st.Vars[order.PlaceOrderVar] = po - res, runErr := engine.Run(r.Context(), def, st) - grain, getErr := s.applier.Get(r.Context(), ordID) + res, runErr := engine.Run(ctx, def, st) + grain, getErr := s.applier.Get(ctx, ordID) if getErr != nil { - writeErr(w, http.StatusInternalServerError, getErr) - return + return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr) } - // 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 { - status = http.StatusPaymentRequired 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 err := s.idem.Put(req.IdempotencyKey, ordID); err != nil { s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err) } } - writeJSON(w, status, map[string]any{ - "orderId": id.String(), - "flow": res, - "order": grain, - }) + return ordID, res, grain, false, runErr, nil } // 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 for _, l := range req.Lines { lineTotal := l.UnitPrice * int64(l.Quantity) - var lineTax int64 - if l.TaxRate > 0 { - // inc-vat -> tax portion = total * rate / (100 + rate) - lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate) - } + lineTax := order.ComputeTax(lineTotal, int(l.TaxRate)) total += lineTotal totalTax += lineTax po.Lines = append(po.Lines, &messages.OrderLine{ diff --git a/cmd/order/main.go b/cmd/order/main.go index 6f2bd2e..bec48f8 100644 --- a/cmd/order/main.go +++ b/cmd/order/main.go @@ -41,6 +41,7 @@ type server struct { freg *flow.Registry flows *flowStore provider order.PaymentProvider + taxProvider order.TaxProvider defaultFlow string dataDir string idem *idempotency.Store @@ -134,9 +135,12 @@ func main() { log.Fatalf("open idempotency store: %v", err) } + taxProvider := selectTaxProvider() + s := &server{ pool: pool, applier: applier, storage: storage, reg: reg, engine: engine, freg: freg, flows: flows, provider: provider, + taxProvider: taxProvider, defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger, } @@ -150,12 +154,14 @@ func main() { mux.HandleFunc("GET /api/orders/{id}", s.handleGet) mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents) - // Post-purchase lifecycle (the grain already supports these transitions). + // Post-purchase lifecycle (the grain already supports these transitions). mux.HandleFunc("POST /api/orders/{id}/fulfillments", s.handleFulfill) mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete) mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel) mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn) 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. orderUCP := ucp.OrderHandler(s.applier) @@ -183,10 +189,9 @@ func main() { mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow) mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow) - // Ingest legacy checkout orders (Klarna/Adyen → order-queue) so this service - // is the single source of orders, superseding go-order-manager. + // Ingest checkout orders from order-queue (Klarna/Adyen fallback). if amqpURL != "" { - startLegacyIngest(context.Background(), amqpURL, applier, logger) + startOrderIngest(context.Background(), amqpURL, s) } 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 } - po := buildPlaceOrder(id, &req) + po := s.buildPlaceOrder(id, &req) st := flow.NewState(uint64(id), s.logger) 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, // 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{ OrderReference: req.OrderReference, CartId: req.CartId, @@ -336,11 +342,7 @@ func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder { var total, totalTax int64 for _, l := range req.Lines { lineTotal := l.UnitPrice * int64(l.Quantity) - var lineTax int64 - if l.TaxRate > 0 { - // inc-vat -> tax portion = total * rate / (100 + rate) - lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate) - } + lineTax := s.taxProvider.ComputeTax(lineTotal, int(l.TaxRate)) total += lineTotal totalTax += lineTax po.Lines = append(po.Lines, &messages.OrderLine{ @@ -450,10 +452,27 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) { // --- 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 // the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for // real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override). func selectProvider(logger *slog.Logger) order.PaymentProvider { + if os.Getenv("PAYMENT_PROVIDER") == "stripe" { key := os.Getenv("STRIPE_SECRET_KEY") if key == "" { diff --git a/cmd/profile/k8s-host-discovery.go b/cmd/profile/k8s-host-discovery.go new file mode 100644 index 0000000..41e475b --- /dev/null +++ b/cmd/profile/k8s-host-discovery.go @@ -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()) +} diff --git a/cmd/profile/main.go b/cmd/profile/main.go index 1b756c4..475e583 100644 --- a/cmd/profile/main.go +++ b/cmd/profile/main.go @@ -17,9 +17,39 @@ import ( "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/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" ) +// 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 // CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and // 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 // (disk storage persists via its own loop), so it's left unset — purge() // 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, PoolSize: 65535, Hostname: podIp, @@ -96,19 +133,40 @@ func main() { 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() debugMux := http.NewServeMux() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - otelShutdown, err := setupOTelSDK(ctx) + otelShutdown, err := telemetry.SetupOTelSDK(ctx) if err != nil { 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 - customerUCP := ucp.CustomerHandler(pool) + auditLogPath := filepath.Join(profileDir, "audit.log") + customerUCP := ucp.CustomerHandler(pool, auditLogPath, credStore) if signer := loadUCPProfileSigner(); signer != nil { customerUCP = ucp.WithSigning(customerUCP, signer) log.Print("ucp customer signing enabled") @@ -119,11 +177,11 @@ func main() { mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP)) // Customer auth: password signup/login + session cookies + identity linking. - credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json")) - if err != nil { - log.Fatalf("Error loading credential store: %v\n", err) - } - authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0).Handler() + authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{ + Limiter: limiter, + BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"), + RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true", + }).Handler() mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler)) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/customerauth/customerauth_test.go b/internal/customerauth/customerauth_test.go index 76b90f4..0e61e50 100644 --- a/internal/customerauth/customerauth_test.go +++ b/internal/customerauth/customerauth_test.go @@ -1,6 +1,7 @@ package customerauth import ( + "context" "path/filepath" "testing" "time" @@ -73,11 +74,12 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) { if err != nil { 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) } // 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) } // Reload from disk and confirm the record persisted. @@ -85,7 +87,7 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) { if err != nil { t.Fatalf("reload: %v", err) } - rec, ok := reloaded.Get("USER@EXAMPLE.COM") + rec, ok := reloaded.Get(ctx, "USER@EXAMPLE.COM") if !ok { t.Fatal("record not found after reload") } @@ -93,3 +95,39 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) { 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") + } +} + diff --git a/internal/customerauth/hardening_test.go b/internal/customerauth/hardening_test.go new file mode 100644 index 0000000..5dda98e --- /dev/null +++ b/internal/customerauth/hardening_test.go @@ -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") + } +} diff --git a/internal/customerauth/notifier.go b/internal/customerauth/notifier.go new file mode 100644 index 0000000..52d8f81 --- /dev/null +++ b/internal/customerauth/notifier.go @@ -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) +} diff --git a/internal/customerauth/ratelimit.go b/internal/customerauth/ratelimit.go new file mode 100644 index 0000000..062fa31 --- /dev/null +++ b/internal/customerauth/ratelimit.go @@ -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() +} diff --git a/internal/customerauth/redis.go b/internal/customerauth/redis.go new file mode 100644 index 0000000..f9734b2 --- /dev/null +++ b/internal/customerauth/redis.go @@ -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:". 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) + } +} diff --git a/internal/customerauth/server.go b/internal/customerauth/server.go index d2e5414..4ca6851 100644 --- a/internal/customerauth/server.go +++ b/internal/customerauth/server.go @@ -3,8 +3,11 @@ package customerauth import ( "context" "encoding/json" + "fmt" "net/http" "net/mail" + "net/url" + "strconv" "strings" "time" @@ -14,9 +17,24 @@ import ( "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 +// 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. // The pool (and the UCP adapter's ProfileApplier) already satisfies it. type ProfileApplier interface { @@ -24,21 +42,60 @@ type ProfileApplier interface { 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 -// by the credential store (email→id+hash) and the profile grain pool. +// Server exposes password signup/login, email verification, password reset and +// identity-linking over HTTP, backed by the credential store (email→id+hash) and +// the profile grain pool. type Server struct { - store *CredentialStore - applier ProfileApplier - signer *Signer - ttl time.Duration + store Credentials + applier ProfileApplier + signer *Signer + 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. -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 { 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 @@ -49,6 +106,10 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /logout", s.handleLogout) 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-checkout", s.handleLinkCheckout) mux.HandleFunc("POST /link-order", s.handleLinkOrder) @@ -86,6 +147,22 @@ type linkOrderRequest struct { 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 // import cycle with internal/ucp). The password hash is never included. type CustomerResponse struct { @@ -98,6 +175,9 @@ type CustomerResponse struct { AvatarURL string `json:"avatarUrl,omitempty"` Addresses []AddressResponse `json:"addresses"` Orders []OrderRef `json:"orders"` + + // EmailVerified reflects whether the email-verification flow has completed. + EmailVerified bool `json:"emailVerified"` } 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") 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") 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 // 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 { writeErr(w, http.StatusConflict, "an account with this email already exists") return @@ -178,6 +258,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { return } + // Send the verification link (best-effort, via the configured notifier). + s.sendVerification(email) + s.issueSession(w, r, 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") 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 // return a single generic error so the response does not reveal whether the // email exists. if !ok || !VerifyPassword(req.Password, rec.Hash) { + s.limiter.RecordFailure(r.Context(), key) writeErr(w, http.StatusUnauthorized, "invalid email or password") 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.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) } +// 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) { id, ok := s.requireSession(w, r) 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) } +// 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 // 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) { @@ -315,7 +518,11 @@ func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status in writeErr(w, http.StatusInternalServerError, "could not read profile") 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 { diff --git a/internal/customerauth/store.go b/internal/customerauth/store.go index 93bfa74..3910e86 100644 --- a/internal/customerauth/store.go +++ b/internal/customerauth/store.go @@ -1,6 +1,7 @@ package customerauth import ( + "context" "encoding/json" "errors" "fmt" @@ -14,14 +15,36 @@ import ( // associated with a credential record. 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 // its password hash. It deliberately does not embed any profile data — the // profile grain remains the source of truth for that. type Record struct { - Email string `json:"email"` - ProfileID uint64 `json:"profileId"` - Hash string `json:"hash"` - CreatedAt string `json:"createdAt,omitempty"` + Email string `json:"email"` + ProfileID uint64 `json:"profileId"` + Hash string `json:"hash"` + CreatedAt string `json:"createdAt,omitempty"` + VerifiedAt string `json:"verifiedAt,omitempty"` } // 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)) } -// Get returns the record for email and whether it exists. -func (s *CredentialStore) Get(email string) (Record, bool) { +// Get returns the record for email and whether it exists. The ctx is accepted +// 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() defer s.mu.RUnlock() 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 // 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) s.mu.Lock() defer s.mu.Unlock() @@ -82,6 +106,54 @@ func (s *CredentialStore) Register(email string, profileID uint64, hash, created 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. func (s *CredentialStore) persistLocked() error { records := make([]Record, 0, len(s.byMail)) diff --git a/internal/customerauth/tokens.go b/internal/customerauth/tokens.go new file mode 100644 index 0000000..552df12 --- /dev/null +++ b/internal/customerauth/tokens.go @@ -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( NUL NUL ) "." 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 +} diff --git a/internal/ucp/checkout.go b/internal/ucp/checkout.go index 1a673af..b0b98e8 100644 --- a/internal/ucp/checkout.go +++ b/internal/ucp/checkout.go @@ -554,7 +554,8 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine { Name: name, Quantity: int32(it.Quantity), 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 { @@ -567,7 +568,7 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine { Name: "Delivery", Quantity: 1, UnitPrice: d.Price.IncVat, - TaxRate: 2500, + TaxRate: 25, }) } return lines diff --git a/internal/ucp/customer.go b/internal/ucp/customer.go index 2b49882..86423a8 100644 --- a/internal/ucp/customer.go +++ b/internal/ucp/customer.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/http" + "os" "time" "git.k6n.net/mats/go-cart-actor/pkg/profile" @@ -15,12 +16,18 @@ import ( // CustomerServer is the UCP REST adapter over the profile grain pool. type CustomerServer struct { - applier ProfileApplier + applier ProfileApplier + deleter CredentialDeleter + auditLogPath string } // NewCustomerServer builds a UCP REST adapter over a profile grain pool. -func NewCustomerServer(applier ProfileApplier) *CustomerServer { - return &CustomerServer{applier: applier} +func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) *CustomerServer { + 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)) } +// 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 // --------------------------------------------------------------------------- diff --git a/internal/ucp/customer_test.go b/internal/ucp/customer_test.go index 4f067d0..1c6b405 100644 --- a/internal/ucp/customer_test.go +++ b/internal/ucp/customer_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "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}) case *messages.LinkOrder: 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]{ @@ -138,7 +146,7 @@ func testProfileID(t *testing.T, applier *testProfileApplier) string { func TestGetCustomer(t *testing.T) { applier := newTestProfileApplier() id := testProfileID(t, applier) - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") // Set up some profile data pid, _ := profile.ParseProfileId(id) @@ -173,7 +181,7 @@ func TestGetCustomer(t *testing.T) { func TestGetCustomerNotFound(t *testing.T) { applier := newTestProfileApplier() - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") // "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet. req := httptest.NewRequest("GET", "/!invalid!", nil) @@ -188,7 +196,7 @@ func TestGetCustomerNotFound(t *testing.T) { func TestUpdateCustomer(t *testing.T) { applier := newTestProfileApplier() id := testProfileID(t, applier) - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}` 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) { applier := newTestProfileApplier() id := testProfileID(t, applier) - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") body := `{ "label": "Home", @@ -266,7 +274,7 @@ func TestAddAddress(t *testing.T) { func TestAddAddressMissingRequired(t *testing.T) { applier := newTestProfileApplier() id := testProfileID(t, applier) - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") // Missing addressLine1 body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}` @@ -283,7 +291,7 @@ func TestAddAddressMissingRequired(t *testing.T) { func TestUpdateAddress(t *testing.T) { applier := newTestProfileApplier() id := testProfileID(t, applier) - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") // First add an address pid, _ := profile.ParseProfileId(id) @@ -337,7 +345,7 @@ func TestUpdateAddress(t *testing.T) { func TestUpdateAddressInvalidID(t *testing.T) { applier := newTestProfileApplier() id := testProfileID(t, applier) - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") body := `{"label": "New Home"}` 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) { applier := newTestProfileApplier() id := testProfileID(t, applier) - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") // First add an address pid, _ := profile.ParseProfileId(id) @@ -398,7 +406,7 @@ func TestRemoveAddressNotFound(t *testing.T) { applier.Get(context.Background(), uint64(pid)) id := pid.String() - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -444,7 +452,7 @@ func TestRemoveAddressNotFoundMutation(t *testing.T) { func TestCustomerHandlerInvalidID(t *testing.T) { applier := newTestProfileApplier() - handler := CustomerHandler(applier) + handler := CustomerHandler(applier, "") // Path "/" now has POST / registered (create customer), so GET / is 405. // 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) } } + +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") + } +} diff --git a/internal/ucp/handler.go b/internal/ucp/handler.go index b25d615..e9f8738 100644 --- a/internal/ucp/handler.go +++ b/internal/ucp/handler.go @@ -86,12 +86,13 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han // To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning: // // mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer)) -func CustomerHandler(applier ProfileApplier) http.Handler { - s := NewCustomerServer(applier) +func CustomerHandler(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) http.Handler { + s := NewCustomerServer(applier, auditLogPath, deleter...) mux := http.NewServeMux() mux.HandleFunc("POST /", s.handleCreateCustomer) mux.HandleFunc("GET /{id}", s.handleGetCustomer) mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer) + mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer) mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress) mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress) mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress) @@ -104,9 +105,11 @@ func CustomerHandler(applier ProfileApplier) http.Handler { // Options controls optional features of the combined handler. type Options struct { - CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions - OrderApplier OrderApplier // optional order creation for complete endpoint - ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking + CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions + OrderApplier OrderApplier // optional order creation for complete endpoint + 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 @@ -148,10 +151,11 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler { // Customer endpoints (optional) if opts.ProfileApplier != nil { - customerSrv := NewCustomerServer(opts.ProfileApplier) + customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter) mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer) mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer) mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer) + mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer) mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress) mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress) mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress) diff --git a/internal/ucp/types.go b/internal/ucp/types.go index 33d1ae1..93dcb56 100644 --- a/internal/ucp/types.go +++ b/internal/ucp/types.go @@ -56,6 +56,11 @@ type ProfileApplier interface { 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 // order from a completed checkout session. It abstracts over the transport // (direct grain pool, HTTP, etc.) so the adapter is portable. diff --git a/pkg/actor/grpc_server.go b/pkg/actor/grpc_server.go index e43a5b3..40239f5 100644 --- a/pkg/actor/grpc_server.go +++ b/pkg/actor/grpc_server.go @@ -9,7 +9,6 @@ import ( "time" messages "git.k6n.net/mats/go-cart-actor/proto/control" - "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" @@ -31,7 +30,6 @@ const name = "grpc_server" var ( tracer = otel.Tracer(name) meter = otel.Meter(name) - logger = otelslog.NewLogger(name) pingCalls metric.Int64Counter negotiateCalls 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.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))) 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.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))) 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.Int("known_hosts_count", len(req.KnownHosts)), ) - logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts)) negotiateCalls.Add(ctx, 1) 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.Int("id_count", len(ids)), ) - logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids)) getLocalActorIdsCalls.Add(ctx, 1) 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("host", host), ) - logger.InfoContext(ctx, "closing notice", "host", host) closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host))) if host != "" { diff --git a/pkg/cart/cart-grain.go b/pkg/cart/cart-grain.go index bd43b81..210a521 100644 --- a/pkg/cart/cart-grain.go +++ b/pkg/cart/cart-grain.go @@ -54,6 +54,8 @@ type CartItem struct { OrderReference string `json:"orderReference,omitempty"` IsSubscribed bool `json:"isSubscribed,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 // the item object in JSON (see cart_item_json.go), so they are returned @@ -152,12 +154,13 @@ type CartGrain struct { } type Voucher struct { - Code string `json:"code"` - Applied bool `json:"applied"` - Rules []string `json:"rules"` - Description string `json:"description,omitempty"` - Id uint32 `json:"id"` - Value int64 `json:"value"` + Code string `json:"code"` + Applied bool `json:"applied"` + Rules []string `json:"rules"` + Description string `json:"description,omitempty"` + Id uint32 `json:"id"` + Value int64 `json:"value"` + BypassedByPromotions bool `json:"-"` } func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) { @@ -298,6 +301,10 @@ func (c *CartGrain) UpdateTotals() { _, ok := voucher.AppliesTo(c) voucher.Applied = false if ok { + if voucher.BypassedByPromotions { + voucher.Applied = true + continue + } value := NewPriceFromIncVat(voucher.Value, 25) if c.TotalPrice.IncVat <= value.IncVat { // don't apply discounts to more than the total price diff --git a/pkg/cart/cart-mutation-helper.go b/pkg/cart/cart-mutation-helper.go index 5f6841e..f1a5c7a 100644 --- a/pkg/cart/cart-mutation-helper.go +++ b/pkg/cart/cart-mutation-helper.go @@ -51,6 +51,12 @@ func (c *CartMutationContext) UseReservations(item *CartItem) bool { if item.ReservationEndTime != nil { return true } + if item.DropShip { + return false + } + if item.InventoryTracked { + return true + } return item.Cgm == "55010" } diff --git a/pkg/cart/mutation_add_item.go b/pkg/cart/mutation_add_item.go index 475ef9e..3e7aa0e 100644 --- a/pkg/cart/mutation_add_item.go +++ b/pkg/cart/mutation_add_item.go @@ -76,6 +76,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er } existing.Quantity += uint16(m.Quantity) 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.StoreId == nil && m.StoreId != nil { existing.StoreId = m.StoreId @@ -144,6 +146,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er Extra: decodeExtra(m.ExtraJson), CustomFields: m.CustomFields, + InventoryTracked: m.InventoryTracked, + DropShip: m.DropShip, } if needsReservation && c.UseReservations(cartItem) { diff --git a/pkg/cart/mutation_change_quantity.go b/pkg/cart/mutation_change_quantity.go index 20ca5bd..915b6c3 100644 --- a/pkg/cart/mutation_change_quantity.go +++ b/pkg/cart/mutation_change_quantity.go @@ -50,7 +50,7 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua if m.Quantity <= 0 { // Remove the item 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) if err != nil { log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId) diff --git a/pkg/cart/tax.go b/pkg/cart/tax.go new file mode 100644 index 0000000..08d9c2e --- /dev/null +++ b/pkg/cart/tax.go @@ -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 } diff --git a/pkg/cart/tax_nordic.go b/pkg/cart/tax_nordic.go new file mode 100644 index 0000000..27d5d48 --- /dev/null +++ b/pkg/cart/tax_nordic.go @@ -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] +} diff --git a/pkg/cart/tax_test.go b/pkg/cart/tax_test.go new file mode 100644 index 0000000..e65eae1 --- /dev/null +++ b/pkg/cart/tax_test.go @@ -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 +} diff --git a/pkg/discovery/discovery.go b/pkg/discovery/discovery.go index c050805..b61f5c8 100644 --- a/pkg/discovery/discovery.go +++ b/pkg/discovery/discovery.go @@ -2,7 +2,9 @@ package discovery import ( "context" + "os" "slices" + "strings" "sync" v1 "k8s.io/api/core/v1" @@ -13,14 +15,33 @@ import ( 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 { ctx context.Context client *kubernetes.Clientset 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) { - return k.DiscoverInNamespace("") + return k.DiscoverInNamespace(k.namespace) } func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) { 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) m := sync.Mutex{} 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}) if err != nil { @@ -77,6 +98,8 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) { 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 { return &K8sDiscovery{ ctx: context.Background(), @@ -84,3 +107,14 @@ func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOption 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, + } +} diff --git a/pkg/order/mutations.go b/pkg/order/mutations.go index 3509b4c..bae5fa9 100644 --- a/pkg/order/mutations.go +++ b/pkg/order/mutations.go @@ -233,6 +233,103 @@ func HandleIssueRefund(o *OrderGrain, m *messages.IssueRefund) error { 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 // the grain pool can apply and replay them. func RegisterMutations(reg actor.MutationRegistry) { @@ -245,5 +342,9 @@ func RegisterMutations(reg actor.MutationRegistry) { actor.NewMutation(HandleCancelOrder), actor.NewMutation(HandleRequestReturn), actor.NewMutation(HandleIssueRefund), + actor.NewMutation(HandleRequestExchange), + actor.NewMutation(HandleEditOrderDetails), ) } + + diff --git a/pkg/order/order-grain.go b/pkg/order/order-grain.go index 031b784..3a1e2eb 100644 --- a/pkg/order/order-grain.go +++ b/pkg/order/order-grain.go @@ -101,6 +101,16 @@ type Return struct { 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. type Refund struct { Provider string `json:"provider"` @@ -137,8 +147,10 @@ type OrderGrain struct { Payments []*Payment `json:"payments,omitempty"` Fulfillments []Fulfillment `json:"fulfillments,omitempty"` Returns []Return `json:"returns,omitempty"` + Exchanges []Exchange `json:"exchanges,omitempty"` Refunds []Refund `json:"refunds,omitempty"` + CapturedAmount int64 `json:"capturedAmount"` RefundedAmount int64 `json:"refundedAmount"` diff --git a/pkg/order/tax.go b/pkg/order/tax.go new file mode 100644 index 0000000..c43f516 --- /dev/null +++ b/pkg/order/tax.go @@ -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() +} diff --git a/pkg/order/tax_test.go b/pkg/order/tax_test.go new file mode 100644 index 0000000..97df97a --- /dev/null +++ b/pkg/order/tax_test.go @@ -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) + } +} diff --git a/pkg/profile/mutation-context.go b/pkg/profile/mutation-context.go index 3a89676..713d3ec 100644 --- a/pkg/profile/mutation-context.go +++ b/pkg/profile/mutation-context.go @@ -16,6 +16,7 @@ func NewProfileMutationRegistry() actor.MutationRegistry { actor.NewMutation(HandleLinkCart), actor.NewMutation(HandleLinkCheckout), actor.NewMutation(HandleLinkOrder), + actor.NewMutation(HandleAnonymizeProfile), ) return reg } diff --git a/pkg/profile/mutation_anonymize.go b/pkg/profile/mutation_anonymize.go new file mode 100644 index 0000000..e5eeada --- /dev/null +++ b/pkg/profile/mutation_anonymize.go @@ -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 +} diff --git a/pkg/promotions/apply.go b/pkg/promotions/apply.go index d7d3834..06e33a5 100644 --- a/pkg/promotions/apply.go +++ b/pkg/promotions/apply.go @@ -2,6 +2,8 @@ package promotions import ( "math" + "slices" + "strings" "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 // took (nil for non-monetary effects such as free shipping) plus whether // 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, // as an open key/value payload. qualified says whether the owning rule // currently applies, letting the effect distinguish "remaining to unlock" @@ -43,6 +45,8 @@ func defaultEffects() map[ActionType]Effect { ActionPercentageDiscount: percentageDiscountEffect{}, ActionTieredDiscount: tieredDiscountEffect{}, ActionFreeShipping: freeShippingEffect{}, + ActionBuyXGetY: buyXGetYEffect{}, + ActionBundleDiscount: bundleDiscountEffect{}, } } @@ -72,7 +76,7 @@ func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationR } recorded := false if res.Applicable { - if discount, applied := eff.Apply(g, a); applied { + if discount, applied := eff.Apply(g, res.Rule, a); applied { entry.Discount = discount recorded = true } @@ -106,7 +110,7 @@ type percentageDiscountEffect struct{} 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)) return d, d != nil } @@ -124,7 +128,7 @@ type freeShippingEffect struct{} 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 } @@ -143,7 +147,7 @@ type tieredDiscountEffect struct{} 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) return d, d != nil } @@ -366,3 +370,361 @@ func firstNum(m map[string]interface{}, keys ...string) float64 { } 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 + } +} diff --git a/pkg/promotions/apply_test.go b/pkg/promotions/apply_test.go index d156c0f..2f3d718 100644 --- a/pkg/promotions/apply_test.go +++ b/pkg/promotions/apply_test.go @@ -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) + } +} diff --git a/pkg/promotions/eval.go b/pkg/promotions/eval.go index 8b3b87b..31af521 100644 --- a/pkg/promotions/eval.go +++ b/pkg/promotions/eval.go @@ -42,6 +42,7 @@ type PromotionEvalContext struct { CustomerLifetimeValue float64 OrderCount int Now time.Time + CouponCodes []string } // 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 } } +// 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. func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext { ctx := &PromotionEvalContext{ @@ -74,6 +80,7 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval CartTotalIncVat: 0, TotalItemQuantity: 0, Now: time.Now(), + CouponCodes: make([]string, 0), } if g.TotalPrice != nil { ctx.CartTotalIncVat = g.TotalPrice.IncVat @@ -91,6 +98,9 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval }) ctx.TotalItemQuantity += uint32(it.Quantity) } + for _, v := range g.Vouchers { + ctx.CouponCodes = append(ctx.CouponCodes, v.Code) + } for _, o := range opts { o(ctx) } @@ -106,18 +116,29 @@ type PromotionService struct { // 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. 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 // 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 { t = NoopTracer{} } - return &PromotionService{ + s := &PromotionService{ tracer: t, 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 @@ -360,11 +381,22 @@ func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool { return evalDayOfWeek(ctx.Now, b) case CondTimeOfDay: return evalTimeOfDay(ctx.Now, b) + case CondCouponCode: + return evalAnyCouponMatch(ctx.CouponCodes, b) default: 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 { if slices.ContainsFunc(ctx.Items, pred) { return true diff --git a/pkg/promotions/eval_test.go b/pkg/promotions/eval_test.go index cbeafe7..02d56bb 100644 --- a/pkg/promotions/eval_test.go +++ b/pkg/promotions/eval_test.go @@ -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 ------------------------------------------------------------- func ptr(s string) *string { return &s } diff --git a/pkg/promotions/evaluate.go b/pkg/promotions/evaluate.go index 059c7e8..8c348a3 100644 --- a/pkg/promotions/evaluate.go +++ b/pkg/promotions/evaluate.go @@ -52,7 +52,7 @@ type EvaluateResponse struct { // Evaluate runs rules against a synthetic cart built from req and returns the // resulting totals and effects without touching any real cart state. func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse { - g := req.toCart() + g := req.toCart(s.DefaultTaxProvider) g.UpdateTotals() ctx := NewContextFromCart(g, req.contextOptions()...) 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. -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() if req.Now != nil { now = *req.Now @@ -78,7 +79,7 @@ func (req EvaluateRequest) toCart() *cart.CartGrain { } vat := it.VatRate if vat == 0 { - vat = 25 + vat = defaultVatRate(tp) } item := &cart.CartItem{ Sku: it.Sku, @@ -91,15 +92,25 @@ func (req EvaluateRequest) toCart() *cart.CartGrain { g.Items = append(g.Items, item) } if len(g.Items) == 0 && req.CartTotalIncVat > 0 { + vat := defaultVatRate(tp) g.Items = append(g.Items, &cart.CartItem{ Sku: "synthetic", Quantity: 1, - Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, 25), + Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, vat), }) } 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. func (req EvaluateRequest) contextOptions() []ContextOption { var opts []ContextOption diff --git a/pkg/promotions/mcp/mcp.go b/pkg/promotions/mcp/mcp.go index d3dbd1d..eeb31e7 100644 --- a/pkg/promotions/mcp/mcp.go +++ b/pkg/promotions/mcp/mcp.go @@ -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 { for _, b := range body { switch b { diff --git a/pkg/promotions/mcp/public.go b/pkg/promotions/mcp/public.go index dc25e82..b8f6903 100644 --- a/pkg/promotions/mcp/public.go +++ b/pkg/promotions/mcp/public.go @@ -86,7 +86,7 @@ func (s *Server) buildPublicTools() []tool { if err := decode(args, &a); err != nil { return nil, err } - g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity) + g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate()) opts := []promotions.ContextOption{promotions.WithNow(time.Now())} if a.CustomerSegment != "" { opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) @@ -141,7 +141,7 @@ func (s *Server) buildPublicTools() []tool { rules = filtered } - g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity) + g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate()) opts := []promotions.ContextOption{promotions.WithNow(time.Now())} if a.CustomerSegment != "" { opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) diff --git a/pkg/promotions/mcp/tools.go b/pkg/promotions/mcp/tools.go index 87c957d..d938771 100644 --- a/pkg/promotions/mcp/tools.go +++ b/pkg/promotions/mcp/tools.go @@ -190,7 +190,7 @@ func (s *Server) buildTools() []tool { if err := decode(args, &a); err != nil { return nil, err } - g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity) + g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate()) opts := []promotions.ContextOption{promotions.WithNow(time.Now())} if 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 -// assumed) so the promotion engine can be evaluated against an arbitrary total. -func syntheticCart(incVat int64, qty int) *cart.CartGrain { +// syntheticCart builds a one-line cart whose gross total is incVat ore so the +// promotion engine can be evaluated against an arbitrary total. defaultVatRate +// 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 { qty = 1 } + if defaultVatRate == 0 { + defaultVatRate = 25 + } g := cart.NewCartGrain(0, time.Now()) 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 // pricing a single unit at the full total. diff --git a/pkg/promotions/types.go b/pkg/promotions/types.go index f9ed6bd..3141c35 100644 --- a/pkg/promotions/types.go +++ b/pkg/promotions/types.go @@ -41,6 +41,7 @@ const ( CondDateRange ConditionType = "date_range" CondDayOfWeek ConditionType = "day_of_week" CondTimeOfDay ConditionType = "time_of_day" + CondCouponCode ConditionType = "coupon_code" CondGroup ConditionType = "group" // synthetic value for groups ) diff --git a/pkg/proxy/remotehost.go b/pkg/proxy/remotehost.go index 5a40675..9aeb13a 100644 --- a/pkg/proxy/remotehost.go +++ b/pkg/proxy/remotehost.go @@ -13,7 +13,6 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/actor" messages "git.k6n.net/mats/go-cart-actor/proto/control" - "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "google.golang.org/grpc" @@ -39,7 +38,6 @@ const name = "proxy" var ( tracer = otel.Tracer(name) meter = otel.Meter(name) - logger = otelslog.NewLogger(name) ) // 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("target", target), ) - logger.InfoContext(ctx, "proxying request", "cartid", id, "host", h.host, "method", r.Method) var bdy io.Reader = r.Body if customBody != nil { diff --git a/pkg/telemetry/logs.go b/pkg/telemetry/logs.go new file mode 100644 index 0000000..d17b215 --- /dev/null +++ b/pkg/telemetry/logs.go @@ -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.0–1.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) } diff --git a/cmd/profile/otel.go b/pkg/telemetry/otel.go similarity index 60% rename from cmd/profile/otel.go rename to pkg/telemetry/otel.go index fc16dc1..c29b5ba 100644 --- a/cmd/profile/otel.go +++ b/pkg/telemetry/otel.go @@ -1,4 +1,4 @@ -package main +package telemetry import ( "context" @@ -6,22 +6,24 @@ import ( "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) { +// SetupOTelSDK bootstraps the OpenTelemetry pipeline (propagator + traces + +// metrics, and an opt-in logger — see NewLoggerProvider). It is shared by every +// 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 err error + // shutdown calls the registered cleanup functions, joining their errors. shutdown := func(ctx context.Context) error { var err error for _, fn := range shutdownFuncs { @@ -31,12 +33,12 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) { return err } + // handleErr runs shutdown and surfaces all errors. handleErr := func(inErr error) { err = errors.Join(inErr, shutdown(ctx)) } - prop := newPropagator() - otel.SetTextMapPropagator(prop) + otel.SetTextMapPropagator(newPropagator()) tracerProvider, err := newTracerProvider() if err != nil { @@ -54,13 +56,17 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) { shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown) 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 { handleErr(err) return shutdown, err } - shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown) - global.SetLoggerProvider(loggerProvider) + if loggerProvider != nil { + shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown) + global.SetLoggerProvider(loggerProvider) + } return shutdown, err } @@ -77,12 +83,9 @@ func newTracerProvider() (*trace.TracerProvider, error) { if err != nil { return nil, err } - - tracerProvider := trace.NewTracerProvider( - trace.WithBatcher(traceExporter, - trace.WithBatchTimeout(time.Second)), - ) - return tracerProvider, nil + return trace.NewTracerProvider( + trace.WithBatcher(traceExporter, trace.WithBatchTimeout(time.Second)), + ), nil } func newMeterProvider() (*metric.MeterProvider, error) { @@ -90,19 +93,5 @@ func newMeterProvider() (*metric.MeterProvider, error) { 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 + return metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter))), nil } diff --git a/proto/cart.proto b/proto/cart.proto index 7613dce..7c7bc76 100644 --- a/proto/cart.proto +++ b/proto/cart.proto @@ -41,6 +41,8 @@ message AddItem { // custom_fields holds optional user-supplied input fields for this line // (e.g. engraving text, configurator notes), keyed by field name. map custom_fields = 28; + bool inventory_tracked = 29; + bool drop_ship = 30; } message RemoveItem { uint32 Id = 1; } diff --git a/proto/cart/cart.pb.go b/proto/cart/cart.pb.go index b68ae98..1c501ad 100644 --- a/proto/cart/cart.pb.go +++ b/proto/cart/cart.pb.go @@ -93,9 +93,11 @@ type AddItem struct { ExtraJson []byte `protobuf:"bytes,27,opt,name=extra_json,json=extraJson,proto3" json:"extra_json,omitempty"` // custom_fields holds optional user-supplied input fields for this line // (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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + 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 + sizeCache protoimpl.SizeCache } func (x *AddItem) Reset() { @@ -324,6 +326,20 @@ func (x *AddItem) GetCustomFields() map[string]string { 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 { state protoimpl.MessageState `protogen:"open.v1"` 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, 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, - 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, 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, @@ -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, 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, - 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, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a, - 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, - 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65, - 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c, - 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, - 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, - 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, - 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, - 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, - 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65, - 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, - 0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, - 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, - 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, - 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, - 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, - 0x22, 0x27, 0x0a, 0x15, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, - 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 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, + 0x64, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x69, 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x5f, + 0x74, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, + 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x12, + 0x1b, 0x0a, 0x09, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x73, 0x68, 0x69, 0x70, 0x18, 0x1e, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x64, 0x72, 0x6f, 0x70, 0x53, 0x68, 0x69, 0x70, 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, 0x42, 0x09, 0x0a, + 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, + 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, + 0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f, + 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e, + 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x22, 0x27, 0x0a, 0x15, 0x52, 0x65, + 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, + 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, - 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, 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, + 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 ( diff --git a/proto/order.proto b/proto/order.proto index dd82068..f8716e5 100644 --- a/proto/order.proto +++ b/proto/order.proto @@ -96,3 +96,24 @@ message IssueRefund { string return_id = 4; // optional linked RMA 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; +} + + + diff --git a/proto/order/order.pb.go b/proto/order/order.pb.go index ab9c562..120c499 100644 --- a/proto/order/order.pb.go +++ b/proto/order/order.pb.go @@ -781,6 +781,160 @@ func (x *IssueRefund) GetAtMs() int64 { 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_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, 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, - 0x18, 0x05, 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, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xe7, 0x01, 0x0a, + 0x0f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, + 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, + 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 ( @@ -906,7 +1084,7 @@ func file_order_proto_rawDescGZIP() []byte { 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{ (*OrderLine)(nil), // 0: order_messages.OrderLine (*PlaceOrder)(nil), // 1: order_messages.PlaceOrder @@ -918,16 +1096,20 @@ var file_order_proto_goTypes = []any{ (*CancelOrder)(nil), // 7: order_messages.CancelOrder (*RequestReturn)(nil), // 8: order_messages.RequestReturn (*IssueRefund)(nil), // 9: order_messages.IssueRefund + (*RequestExchange)(nil), // 10: order_messages.RequestExchange + (*EditOrderDetails)(nil), // 11: order_messages.EditOrderDetails } var file_order_proto_depIdxs = []int32{ 0, // 0: order_messages.PlaceOrder.lines:type_name -> order_messages.OrderLine 4, // 1: order_messages.CreateFulfillment.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 - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 4, // 3: order_messages.RequestExchange.return_lines:type_name -> order_messages.FulfillmentLine + 0, // 4: order_messages.RequestExchange.new_lines:type_name -> order_messages.OrderLine + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 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() } @@ -941,7 +1123,7 @@ func file_order_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_order_proto_rawDesc), len(file_order_proto_rawDesc)), NumEnums: 0, - NumMessages: 10, + NumMessages: 12, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/profile.proto b/proto/profile.proto index cd9122d..78be0c8 100644 --- a/proto/profile.proto +++ b/proto/profile.proto @@ -77,6 +77,9 @@ message LinkOrder { string status = 3; // order status snapshot } +// AnonymizeProfile wipes all PII from the customer profile. +message AnonymizeProfile {} + message Mutation { oneof type { SetProfile set_profile = 1; @@ -86,5 +89,7 @@ message Mutation { LinkCart link_cart = 5; LinkCheckout link_checkout = 6; LinkOrder link_order = 7; + AnonymizeProfile anonymize_profile = 8; } } + diff --git a/proto/profile/profile.pb.go b/proto/profile/profile.pb.go index 1f93e67..a073030 100644 --- a/proto/profile/profile.pb.go +++ b/proto/profile/profile.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.5 // protoc v7.35.0 -// source: proto/profile.proto +// source: profile.proto package profile_messages @@ -42,7 +42,7 @@ type Address struct { func (x *Address) Reset() { *x = Address{} - mi := &file_proto_profile_proto_msgTypes[0] + mi := &file_profile_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -54,7 +54,7 @@ func (x *Address) String() string { func (*Address) ProtoMessage() {} func (x *Address) ProtoReflect() protoreflect.Message { - mi := &file_proto_profile_proto_msgTypes[0] + mi := &file_profile_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -67,7 +67,7 @@ func (x *Address) ProtoReflect() protoreflect.Message { // Deprecated: Use Address.ProtoReflect.Descriptor instead. 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 { @@ -169,7 +169,7 @@ type SetProfile struct { func (x *SetProfile) Reset() { *x = SetProfile{} - mi := &file_proto_profile_proto_msgTypes[1] + mi := &file_profile_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -181,7 +181,7 @@ func (x *SetProfile) String() string { func (*SetProfile) ProtoMessage() {} func (x *SetProfile) ProtoReflect() protoreflect.Message { - mi := &file_proto_profile_proto_msgTypes[1] + mi := &file_profile_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -194,7 +194,7 @@ func (x *SetProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use SetProfile.ProtoReflect.Descriptor instead. 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 { @@ -249,7 +249,7 @@ type AddAddress struct { func (x *AddAddress) Reset() { *x = AddAddress{} - mi := &file_proto_profile_proto_msgTypes[2] + mi := &file_profile_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -261,7 +261,7 @@ func (x *AddAddress) String() string { func (*AddAddress) ProtoMessage() {} func (x *AddAddress) ProtoReflect() protoreflect.Message { - mi := &file_proto_profile_proto_msgTypes[2] + mi := &file_profile_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -274,7 +274,7 @@ func (x *AddAddress) ProtoReflect() protoreflect.Message { // Deprecated: Use AddAddress.ProtoReflect.Descriptor instead. 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 { @@ -305,7 +305,7 @@ type UpdateAddress struct { func (x *UpdateAddress) Reset() { *x = UpdateAddress{} - mi := &file_proto_profile_proto_msgTypes[3] + mi := &file_profile_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -317,7 +317,7 @@ func (x *UpdateAddress) String() string { func (*UpdateAddress) ProtoMessage() {} func (x *UpdateAddress) ProtoReflect() protoreflect.Message { - mi := &file_proto_profile_proto_msgTypes[3] + mi := &file_profile_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -330,7 +330,7 @@ func (x *UpdateAddress) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateAddress.ProtoReflect.Descriptor instead. 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 { @@ -427,7 +427,7 @@ type RemoveAddress struct { func (x *RemoveAddress) Reset() { *x = RemoveAddress{} - mi := &file_proto_profile_proto_msgTypes[4] + mi := &file_profile_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -439,7 +439,7 @@ func (x *RemoveAddress) String() string { func (*RemoveAddress) ProtoMessage() {} func (x *RemoveAddress) ProtoReflect() protoreflect.Message { - mi := &file_proto_profile_proto_msgTypes[4] + mi := &file_profile_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -452,7 +452,7 @@ func (x *RemoveAddress) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveAddress.ProtoReflect.Descriptor instead. 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 { @@ -473,7 +473,7 @@ type LinkCart struct { func (x *LinkCart) Reset() { *x = LinkCart{} - mi := &file_proto_profile_proto_msgTypes[5] + mi := &file_profile_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -485,7 +485,7 @@ func (x *LinkCart) String() string { func (*LinkCart) ProtoMessage() {} func (x *LinkCart) ProtoReflect() protoreflect.Message { - mi := &file_proto_profile_proto_msgTypes[5] + mi := &file_profile_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -498,7 +498,7 @@ func (x *LinkCart) ProtoReflect() protoreflect.Message { // Deprecated: Use LinkCart.ProtoReflect.Descriptor instead. 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 { @@ -526,7 +526,7 @@ type LinkCheckout struct { func (x *LinkCheckout) Reset() { *x = LinkCheckout{} - mi := &file_proto_profile_proto_msgTypes[6] + mi := &file_profile_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -538,7 +538,7 @@ func (x *LinkCheckout) String() string { func (*LinkCheckout) ProtoMessage() {} func (x *LinkCheckout) ProtoReflect() protoreflect.Message { - mi := &file_proto_profile_proto_msgTypes[6] + mi := &file_profile_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -551,7 +551,7 @@ func (x *LinkCheckout) ProtoReflect() protoreflect.Message { // Deprecated: Use LinkCheckout.ProtoReflect.Descriptor instead. 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 { @@ -580,7 +580,7 @@ type LinkOrder struct { func (x *LinkOrder) Reset() { *x = LinkOrder{} - mi := &file_proto_profile_proto_msgTypes[7] + mi := &file_profile_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -592,7 +592,7 @@ func (x *LinkOrder) String() string { func (*LinkOrder) ProtoMessage() {} func (x *LinkOrder) ProtoReflect() protoreflect.Message { - mi := &file_proto_profile_proto_msgTypes[7] + mi := &file_profile_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -605,7 +605,7 @@ func (x *LinkOrder) ProtoReflect() protoreflect.Message { // Deprecated: Use LinkOrder.ProtoReflect.Descriptor instead. 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 { @@ -629,6 +629,43 @@ func (x *LinkOrder) GetStatus() string { 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 { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Type: @@ -640,6 +677,7 @@ type Mutation struct { // *Mutation_LinkCart // *Mutation_LinkCheckout // *Mutation_LinkOrder + // *Mutation_AnonymizeProfile Type isMutation_Type `protobuf_oneof:"type"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -647,7 +685,7 @@ type Mutation struct { func (x *Mutation) Reset() { *x = Mutation{} - mi := &file_proto_profile_proto_msgTypes[8] + mi := &file_profile_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -659,7 +697,7 @@ func (x *Mutation) String() string { func (*Mutation) ProtoMessage() {} func (x *Mutation) ProtoReflect() protoreflect.Message { - mi := &file_proto_profile_proto_msgTypes[8] + mi := &file_profile_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -672,7 +710,7 @@ func (x *Mutation) ProtoReflect() protoreflect.Message { // Deprecated: Use Mutation.ProtoReflect.Descriptor instead. 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 { @@ -745,6 +783,15 @@ func (x *Mutation) GetLinkOrder() *LinkOrder { 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 { isMutation_Type() } @@ -777,6 +824,10 @@ type Mutation_LinkOrder struct { 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_AddAddress) isMutation_Type() {} @@ -791,170 +842,179 @@ func (*Mutation_LinkCheckout) 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{ - 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, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xfe, 0x02, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72, - 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, - 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x75, 0x6c, - 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x75, 0x6c, - 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, - 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, - 0x00, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, - 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, - 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x12, 0x18, - 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, - 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, - 0x88, 0x01, 0x01, 0x12, 0x2c, 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, 0x52, 0x11, - 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x12, 0x2a, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, - 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44, - 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, +var File_profile_proto protoreflect.FileDescriptor + +var file_profile_proto_rawDesc = string([]byte{ + 0x0a, 0x0d, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x22, 0xfe, 0x02, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, + 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, + 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, + 0x62, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x22, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, + 0x6e, 0x65, 0x31, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, + 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, + 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, + 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, + 0x09, 0x48, 0x01, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x2c, 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, 0x52, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, + 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x2a, 0x0a, 0x10, 0x69, 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, - 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, - 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 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, 0xe8, 0x03, 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, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, - 0x73, 0x48, 0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, - 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, - 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72, - 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09, - 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c, - 0x69, 0x6e, 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, + 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44, 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, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48, + 0x0a, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, + 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 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, - 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, 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, + 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, + 0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, + 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, + 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, + 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09, 0x6c, 0x69, + 0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x69, 0x6e, + 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 ( - file_proto_profile_proto_rawDescOnce sync.Once - file_proto_profile_proto_rawDescData []byte + file_profile_proto_rawDescOnce sync.Once + file_profile_proto_rawDescData []byte ) -func file_proto_profile_proto_rawDescGZIP() []byte { - file_proto_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))) +func file_profile_proto_rawDescGZIP() []byte { + file_profile_proto_rawDescOnce.Do(func() { + 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_proto_profile_proto_goTypes = []any{ - (*Address)(nil), // 0: profile_messages.Address - (*SetProfile)(nil), // 1: profile_messages.SetProfile - (*AddAddress)(nil), // 2: profile_messages.AddAddress - (*UpdateAddress)(nil), // 3: profile_messages.UpdateAddress - (*RemoveAddress)(nil), // 4: profile_messages.RemoveAddress - (*LinkCart)(nil), // 5: profile_messages.LinkCart - (*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout - (*LinkOrder)(nil), // 7: profile_messages.LinkOrder - (*Mutation)(nil), // 8: profile_messages.Mutation +var file_profile_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_profile_proto_goTypes = []any{ + (*Address)(nil), // 0: profile_messages.Address + (*SetProfile)(nil), // 1: profile_messages.SetProfile + (*AddAddress)(nil), // 2: profile_messages.AddAddress + (*UpdateAddress)(nil), // 3: profile_messages.UpdateAddress + (*RemoveAddress)(nil), // 4: profile_messages.RemoveAddress + (*LinkCart)(nil), // 5: profile_messages.LinkCart + (*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout + (*LinkOrder)(nil), // 7: profile_messages.LinkOrder + (*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 1, // 1: profile_messages.Mutation.set_profile:type_name -> profile_messages.SetProfile 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 6, // 6: profile_messages.Mutation.link_checkout:type_name -> profile_messages.LinkCheckout 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:8] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 8, // 8: profile_messages.Mutation.anonymize_profile:type_name -> profile_messages.AnonymizeProfile + 9, // [9:9] is the sub-list for method output_type + 9, // [9:9] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension 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 file_proto_profile_proto_init() { - if File_proto_profile_proto != nil { +func init() { file_profile_proto_init() } +func file_profile_proto_init() { + if File_profile_proto != nil { return } - file_proto_profile_proto_msgTypes[0].OneofWrappers = []any{} - file_proto_profile_proto_msgTypes[1].OneofWrappers = []any{} - file_proto_profile_proto_msgTypes[3].OneofWrappers = []any{} - file_proto_profile_proto_msgTypes[8].OneofWrappers = []any{ + file_profile_proto_msgTypes[0].OneofWrappers = []any{} + file_profile_proto_msgTypes[1].OneofWrappers = []any{} + file_profile_proto_msgTypes[3].OneofWrappers = []any{} + file_profile_proto_msgTypes[9].OneofWrappers = []any{ (*Mutation_SetProfile)(nil), (*Mutation_AddAddress)(nil), (*Mutation_UpdateAddress)(nil), @@ -986,22 +1047,23 @@ func file_proto_profile_proto_init() { (*Mutation_LinkCart)(nil), (*Mutation_LinkCheckout)(nil), (*Mutation_LinkOrder)(nil), + (*Mutation_AnonymizeProfile)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ 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, - NumMessages: 9, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_proto_profile_proto_goTypes, - DependencyIndexes: file_proto_profile_proto_depIdxs, - MessageInfos: file_proto_profile_proto_msgTypes, + GoTypes: file_profile_proto_goTypes, + DependencyIndexes: file_profile_proto_depIdxs, + MessageInfos: file_profile_proto_msgTypes, }.Build() - File_proto_profile_proto = out.File - file_proto_profile_proto_goTypes = nil - file_proto_profile_proto_depIdxs = nil + File_profile_proto = out.File + file_profile_proto_goTypes = nil + file_profile_proto_depIdxs = nil }