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

This commit is contained in:
2026-06-27 19:49:00 +02:00
parent 492f54ff45
commit 528c59bfd3
67 changed files with 3618 additions and 1031 deletions
+7 -1
View File
@@ -19,11 +19,12 @@
MODULE_PATH := git.k6n.net/mats/go-cart-actor
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:
+3 -1
View File
@@ -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,
})
+74 -27
View File
@@ -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)
}
-117
View File
@@ -1,117 +0,0 @@
package main
import (
"context"
"errors"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/trace"
)
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
// If it does not return an error, make sure to call shutdown for proper cleanup.
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
var shutdownFuncs []func(context.Context) error
var err error
// shutdown calls cleanup functions registered via shutdownFuncs.
// The errors from the calls are joined.
// Each registered cleanup will be invoked once.
shutdown := func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
// Set up propagator.
prop := newPropagator()
otel.SetTextMapPropagator(prop)
// Set up trace provider.
tracerProvider, err := newTracerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
// Set up meter provider.
meterProvider, err := newMeterProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
// Set up logger provider.
loggerProvider, err := newLoggerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider)
return shutdown, err
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
func newTracerProvider() (*trace.TracerProvider, error) {
traceExporter, err := otlptracegrpc.New(context.Background())
if err != nil {
return nil, err
}
tracerProvider := trace.NewTracerProvider(
trace.WithBatcher(traceExporter,
// Default is 5s. Set to 1s for demonstrative purposes.
trace.WithBatchTimeout(time.Second)),
)
return tracerProvider, nil
}
func newMeterProvider() (*metric.MeterProvider, error) {
exporter, err := otlpmetricgrpc.New(context.Background())
if err != nil {
return nil, err
}
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
return provider, nil
}
func newLoggerProvider() (*log.LoggerProvider, error) {
logExporter, err := otlploggrpc.New(context.Background())
if err != nil {
return nil, err
}
loggerProvider := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExporter)),
)
return loggerProvider, nil
}
-3
View File
@@ -17,7 +17,6 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
"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
)
+13 -3
View File
@@ -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",
+73 -13
View File
@@ -64,7 +64,9 @@ type CheckoutMeta struct {
//
// If you later need to support different tax rates per line, you can extend
// 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),
})
}
+3 -1
View File
@@ -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,
})
+24 -64
View File
@@ -193,6 +193,18 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway",
"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,23 +221,13 @@ 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 {
// ── 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())
}
}
// ── Dual-write: AMQP order-queue for legacy consumers ───────────────
if s.orderHandler != nil {
legacyBody, _ := buildLegacyOrderJSON(grain, order, "klarna", orderId)
if pubErr := s.orderHandler.OrderCompleted(legacyBody); pubErr != nil {
logger.WarnContext(r.Context(), "order-queue publish failed", "err", pubErr)
}
}
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil {
@@ -264,59 +266,7 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
})
}
// buildLegacyOrderJSON builds the go-order-manager compatible JSON for AMQP
// dual-write, so existing downstream consumers on the order-queue still receive
// order events during the migration period.
func buildLegacyOrderJSON(grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string, orderId string) ([]byte, error) {
if grain.CartState == nil {
return nil, fmt.Errorf("buildLegacyOrderJSON: checkout %s has no cart state", grain.Id)
}
type legacyLine struct {
Reference string `json:"reference"`
Name string `json:"name"`
Quantity int `json:"quantity"`
UnitPrice int `json:"unit_price"`
TaxRate int `json:"tax_rate"`
TotalAmount int `json:"total_amount"`
TotalTaxAmount int `json:"total_tax_amount"`
}
type legacyOrder struct {
ID string `json:"order_id"`
PurchaseCountry string `json:"purchase_country"`
PurchaseCurrency string `json:"purchase_currency"`
Locale string `json:"locale"`
OrderAmount int `json:"order_amount"`
OrderTaxAmount int `json:"order_tax_amount"`
OrderLines []legacyLine `json:"order_lines"`
}
lines := make([]legacyLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lines = append(lines, legacyLine{
Reference: it.Sku,
Name: it.Meta.Name,
Quantity: int(it.Quantity),
UnitPrice: int(it.Price.IncVat),
TaxRate: it.Tax,
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
})
}
lo := legacyOrder{
ID: orderId,
PurchaseCountry: klarnaOrder.PurchaseCountry,
PurchaseCurrency: klarnaOrder.PurchaseCurrency,
Locale: klarnaOrder.Locale,
OrderAmount: klarnaOrder.OrderAmount,
OrderTaxAmount: klarnaOrder.OrderTaxAmount,
OrderLines: lines,
}
return json.Marshal(lo)
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
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{
+40 -5
View File
@@ -12,14 +12,16 @@ import (
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/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)
}
+82 -10
View File
@@ -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,11 +142,10 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
req.Payment.Reference = pay.Reference
req.Payment.Amount = pay.Amount
var createErr error
if s.orderClient != nil {
result, err := s.orderClient.CreateOrder(ctx, req, "")
if err != nil {
return nil, err
}
if err == nil {
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
OrderId: result.OrderId,
Status: "completed",
@@ -108,3 +153,30 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
})
return result, nil
}
createErr = err
logger.WarnContext(ctx, "from-checkout HTTP call failed; falling back to AMQP", "err", err, "checkoutId", grain.Id.String())
}
// Fallback to AMQP
if s.orderHandler != nil {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal order fallback: %w", err)
}
if pubErr := s.orderHandler.OrderCompleted(body); pubErr != nil {
if createErr != nil {
return nil, fmt.Errorf("AMQP fallback failed: %w (HTTP error: %v)", pubErr, createErr)
}
return nil, fmt.Errorf("AMQP fallback failed: %w", pubErr)
}
logger.InfoContext(ctx, "order request published to AMQP successfully as fallback", "checkoutId", grain.Id.String())
return &CreateOrderResult{
OrderId: "queued-" + grain.Id.String(),
}, nil
}
if createErr != nil {
return nil, createErr
}
return nil, fmt.Errorf("neither order client nor AMQP order handler configured")
}
-117
View File
@@ -1,117 +0,0 @@
package main
import (
"context"
"errors"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/trace"
)
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
// If it does not return an error, make sure to call shutdown for proper cleanup.
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
var shutdownFuncs []func(context.Context) error
var err error
// shutdown calls cleanup functions registered via shutdownFuncs.
// The errors from the calls are joined.
// Each registered cleanup will be invoked once.
shutdown := func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
// Set up propagator.
prop := newPropagator()
otel.SetTextMapPropagator(prop)
// Set up trace provider.
tracerProvider, err := newTracerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
// Set up meter provider.
meterProvider, err := newMeterProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
// Set up logger provider.
loggerProvider, err := newLoggerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider)
return shutdown, err
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
func newTracerProvider() (*trace.TracerProvider, error) {
traceExporter, err := otlptracegrpc.New(context.Background())
if err != nil {
return nil, err
}
tracerProvider := trace.NewTracerProvider(
trace.WithBatcher(traceExporter,
// Default is 5s. Set to 1s for demonstrative purposes.
trace.WithBatchTimeout(time.Second)),
)
return tracerProvider, nil
}
func newMeterProvider() (*metric.MeterProvider, error) {
exporter, err := otlpmetricgrpc.New(context.Background())
if err != nil {
return nil, err
}
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
return provider, nil
}
func newLoggerProvider() (*log.LoggerProvider, error) {
logExporter, err := otlploggrpc.New(context.Background())
if err != nil {
return nil, err
}
loggerProvider := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExporter)),
)
return loggerProvider, nil
}
+26 -6
View File
@@ -52,6 +52,8 @@ type CheckoutPoolServer struct {
orderClient *OrderClient
orderHandler *AmqpOrderHandler
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)
}
}
}
+1 -2
View File
@@ -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))
+22 -105
View File
@@ -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)
}
}
}
+50 -12
View File
@@ -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)
}
func TestIngestLegacyOrder(t *testing.T) {
app := testApplier(t)
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 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)
}
+146 -4
View File
@@ -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 {
+44 -53
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -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.
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)
ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err))
writeErr(w, http.StatusInternalServerError, err)
return
}
if exists {
writeJSON(w, http.StatusConflict, map[string]any{
"orderId": order.OrderId(existingID).String(),
"order": g,
"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 {
g, err := s.applier.Get(ctx, existingID)
if err != nil {
return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err)
}
return existingID, nil, g, true, nil, nil
}
}
// ── Create the order grain ────────────────────────────────────────
id, err := order.NewOrderId()
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{
+29 -10
View File
@@ -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,
}
@@ -156,6 +160,8 @@ func main() {
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 == "" {
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"log"
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// GetDiscovery returns the k8s pod-watcher that finds peer profile replicas, or
// nil when POD_IP is unset (single-node / local dev — clustering disabled). The
// watch is scoped to this pod's own namespace so it only needs a namespaced Role
// (pods: get/list/watch), not a cluster-wide role.
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
timeout := int64(30)
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
LabelSelector: "actor-pool=profile",
TimeoutSeconds: &timeout,
})
}
// UseDiscovery starts the watch loop that adds/removes peer hosts from the pool
// as profile replicas become ready or go away. A nil discovery (POD_IP unset)
// logs and returns, leaving the pool in single-node mode.
func UseDiscovery(pool discovery.DiscoveryTarget) {
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.IsReady {
case false:
if pool.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemoteHost(evt.Host)
}
}
}
}(GetDiscovery())
}
+65 -7
View File
@@ -17,9 +17,39 @@ import (
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/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) {
+41 -3
View File
@@ -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")
}
}
+115
View File
@@ -0,0 +1,115 @@
package customerauth
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestPurposeTokenRoundTrip(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.IssuePurpose(purposePasswordReset, "user@example.com", time.Hour)
sub, err := s.ParsePurpose(purposePasswordReset, tok)
if err != nil {
t.Fatalf("parse: %v", err)
}
if sub != "user@example.com" {
t.Fatalf("got subject %q, want user@example.com", sub)
}
}
func TestPurposeTokenWrongPurposeRejected(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
// A verify token must not be accepted as a reset token.
if _, err := s.ParsePurpose(purposePasswordReset, tok); err != ErrInvalidToken {
t.Fatalf("got %v, want ErrInvalidToken", err)
}
}
func TestPurposeTokenExpiredAndTampered(t *testing.T) {
s := NewSigner([]byte("test-secret"))
expired := s.IssuePurpose(purposeVerifyEmail, "user@example.com", -time.Second)
if _, err := s.ParsePurpose(purposeVerifyEmail, expired); err != ErrExpiredToken {
t.Fatalf("expired: got %v, want ErrExpiredToken", err)
}
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
if _, err := s.ParsePurpose(purposeVerifyEmail, tok+"x"); err != ErrInvalidToken {
t.Fatalf("tampered: got %v, want ErrInvalidToken", err)
}
}
func TestLoginLimiterLocksOutAndResets(t *testing.T) {
ctx := context.Background()
l := NewLoginLimiter(3, time.Minute)
const key = "user@example.com"
for i := range 3 {
if ok, _ := l.Allowed(ctx, key); !ok {
t.Fatalf("locked out early after %d failures", i)
}
l.RecordFailure(ctx, key)
}
ok, retry := l.Allowed(ctx, key)
if ok {
t.Fatal("expected lockout after 3 failures")
}
if retry <= 0 {
t.Fatalf("expected positive retry-after, got %v", retry)
}
// A successful auth clears the history.
l.Reset(ctx, key)
if ok, _ := l.Allowed(ctx, key); !ok {
t.Fatal("expected reset to clear lockout")
}
}
func TestLoginLimiterNilIsNoop(t *testing.T) {
ctx := context.Background()
var l *LoginLimiter
if ok, _ := l.Allowed(ctx, "x"); !ok {
t.Fatal("nil limiter should allow")
}
l.RecordFailure(ctx, "x") // must not panic
l.Reset(ctx, "x") // must not panic
}
func TestStoreMarkVerifiedAndUpdateHash(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "credentials.json")
store, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if err := store.Register(ctx, "u@example.com", 1, "hash1", "ts"); err != nil {
t.Fatalf("register: %v", err)
}
// Unknown email is a no-op, not an error.
if found, err := store.MarkVerified(ctx, "missing@example.com", "now"); err != nil || found {
t.Fatalf("MarkVerified(missing) = (%v, %v), want (false, nil)", found, err)
}
if found, err := store.MarkVerified(ctx, "U@EXAMPLE.COM", "2026-01-01T00:00:00Z"); err != nil || !found {
t.Fatalf("MarkVerified = (%v, %v), want (true, nil)", found, err)
}
if found, err := store.UpdateHash(ctx, "u@example.com", "hash2"); err != nil || !found {
t.Fatalf("UpdateHash = (%v, %v), want (true, nil)", found, err)
}
// Both changes survive a reload from disk.
reloaded, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
rec, ok := reloaded.Get(ctx, "u@example.com")
if !ok {
t.Fatal("record missing after reload")
}
if rec.Hash != "hash2" {
t.Fatalf("hash = %q, want hash2", rec.Hash)
}
if rec.VerifiedAt == "" {
t.Fatal("verifiedAt not persisted")
}
}
+24
View File
@@ -0,0 +1,24 @@
package customerauth
import "log"
// Notifier delivers transactional auth messages (email verification and
// password reset). The auth server only builds the links; delivery is pluggable
// so this is the seam where a real mailer — or the planned notification service
// (commerce-maturity plan, section C3) — gets wired in. The default LogNotifier
// just logs the link, which is enough for local development.
type Notifier interface {
SendEmailVerification(email, link string)
SendPasswordReset(email, link string)
}
// LogNotifier writes the links to the standard logger instead of sending them.
type LogNotifier struct{}
func (LogNotifier) SendEmailVerification(email, link string) {
log.Printf("customerauth: email verification for %s: %s", email, link)
}
func (LogNotifier) SendPasswordReset(email, link string) {
log.Printf("customerauth: password reset for %s: %s", email, link)
}
+104
View File
@@ -0,0 +1,104 @@
package customerauth
import (
"context"
"sync"
"time"
)
// Limiter throttles repeated failures for a key (login email or "reset:"-prefixed
// email). It is satisfied by the in-memory LoginLimiter (single-instance / dev)
// and by RedisLoginLimiter (shared, horizontally scalable).
type Limiter interface {
// Allowed reports whether an attempt for key may proceed, and when locked the
// remaining lockout duration (for a Retry-After header).
Allowed(ctx context.Context, key string) (bool, time.Duration)
// RecordFailure registers a failed attempt for key.
RecordFailure(ctx context.Context, key string)
// Reset clears the failure history for key (call on a successful auth).
Reset(ctx context.Context, key string)
}
// LoginLimiter is an in-memory failed-attempt limiter that locks a key out after
// too many failures inside a rolling window. It is per-process — matching the
// credential store's single-instance scope; a horizontally-scaled deployment
// would need a shared backing store. Keys are typically the normalized email
// (login) or a "reset:"-prefixed email (reset requests).
type LoginLimiter struct {
mu sync.Mutex
attempts map[string]*attemptState
max int // failures allowed in the window before lockout
window time.Duration // rolling window and lockout duration
}
type attemptState struct {
count int
first time.Time
lockedUntil time.Time
}
// NewLoginLimiter builds a limiter allowing max failures per window before a
// lockout of window duration. Zero/negative values fall back to 5 per 15m.
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
if max <= 0 {
max = 5
}
if window <= 0 {
window = 15 * time.Minute
}
return &LoginLimiter{attempts: make(map[string]*attemptState), max: max, window: window}
}
// Allowed reports whether an attempt for key may proceed. When locked it also
// returns the remaining lockout duration (suitable for a Retry-After header). A
// nil limiter always allows (disabled).
func (l *LoginLimiter) Allowed(_ context.Context, key string) (bool, time.Duration) {
if l == nil {
return true, 0
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.attempts[key]
if st == nil {
return true, 0
}
if now.Before(st.lockedUntil) {
return false, st.lockedUntil.Sub(now)
}
// Window elapsed since the first failure: forget the history.
if now.Sub(st.first) > l.window {
delete(l.attempts, key)
}
return true, 0
}
// RecordFailure registers a failed attempt for key, locking it for window once
// max failures accumulate inside the window.
func (l *LoginLimiter) RecordFailure(_ context.Context, key string) {
if l == nil {
return
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.attempts[key]
if st == nil || now.Sub(st.first) > l.window {
l.attempts[key] = &attemptState{count: 1, first: now}
return
}
st.count++
if st.count >= l.max {
st.lockedUntil = now.Add(l.window)
}
}
// Reset clears the failure history for key. Call it on a successful auth.
func (l *LoginLimiter) Reset(_ context.Context, key string) {
if l == nil {
return
}
l.mu.Lock()
delete(l.attempts, key)
l.mu.Unlock()
}
+215
View File
@@ -0,0 +1,215 @@
package customerauth
import (
"context"
"log"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// Redis-backed implementations of Credentials and Limiter. They let the profile
// service run horizontally: every replica reads/writes the same credential
// records and shares one failed-attempt counter, instead of each holding its own
// file map and in-process counter. Session and verify/reset tokens are already
// stateless HMAC values, so Redis + a shared CUSTOMER_AUTH_SECRET is all that
// the auth surface needs to scale out.
const (
credKeyPrefix = "customerauth:cred:" // hash per normalized email
failKeyPrefix = "customerauth:fail:" // failure counter per key
lockKeyPrefix = "customerauth:lock:" // lockout marker per key
)
// Compile-time guarantees that both backings satisfy the server's interfaces.
var (
_ Credentials = (*CredentialStore)(nil)
_ Credentials = (*RedisCredentialStore)(nil)
_ Limiter = (*LoginLimiter)(nil)
_ Limiter = (*RedisLoginLimiter)(nil)
)
// ---------------------------------------------------------------------------
// RedisCredentialStore
// ---------------------------------------------------------------------------
// RedisCredentialStore stores each credential as a Redis hash at
// "customerauth:cred:<normalized-email>". Register/MarkVerified/UpdateHash are
// atomic via small Lua scripts so concurrent replicas can't race a duplicate
// registration or a lost update.
type RedisCredentialStore struct {
client *redis.Client
luaRegister *redis.Script
luaVerified *redis.Script
luaUpdHash *redis.Script
}
// registerScript creates the hash only if it does not already exist, returning 1
// on create and 0 if the email is taken.
const registerScript = `
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
redis.call('HSET', KEYS[1], 'email', ARGV[1], 'profileId', ARGV[2], 'hash', ARGV[3], 'createdAt', ARGV[4])
return 1`
// markVerifiedScript sets verifiedAt only when missing. Returns -1 if the email
// is unknown, 1 otherwise (whether it set the field now or was already verified).
const markVerifiedScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end
local v = redis.call('HGET', KEYS[1], 'verifiedAt')
if v and v ~= '' then return 1 end
redis.call('HSET', KEYS[1], 'verifiedAt', ARGV[1])
return 1`
// updateHashScript replaces the password hash. Returns 0 if unknown, 1 on update.
const updateHashScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
redis.call('HSET', KEYS[1], 'hash', ARGV[1])
return 1`
// NewRedisCredentialStore builds a Redis-backed credential store and verifies
// connectivity with a ping.
func NewRedisCredentialStore(ctx context.Context, client *redis.Client) (*RedisCredentialStore, error) {
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return &RedisCredentialStore{
client: client,
luaRegister: redis.NewScript(registerScript),
luaVerified: redis.NewScript(markVerifiedScript),
luaUpdHash: redis.NewScript(updateHashScript),
}, nil
}
func (s *RedisCredentialStore) Get(ctx context.Context, email string) (Record, bool) {
key := credKeyPrefix + NormalizeEmail(email)
m, err := s.client.HGetAll(ctx, key).Result()
if err != nil {
log.Printf("customerauth: redis Get(%s): %v", key, err)
return Record{}, false
}
if len(m) == 0 {
return Record{}, false
}
pid, _ := strconv.ParseUint(m["profileId"], 10, 64)
return Record{
Email: m["email"],
ProfileID: pid,
Hash: m["hash"],
CreatedAt: m["createdAt"],
VerifiedAt: m["verifiedAt"],
}, true
}
func (s *RedisCredentialStore) Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error {
norm := NormalizeEmail(email)
created, err := s.luaRegister.Run(ctx, s.client,
[]string{credKeyPrefix + norm},
norm, strconv.FormatUint(profileID, 10), hash, createdAt,
).Int()
if err != nil {
return err
}
if created == 0 {
return ErrEmailExists
}
return nil
}
func (s *RedisCredentialStore) MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error) {
res, err := s.luaVerified.Run(ctx, s.client,
[]string{credKeyPrefix + NormalizeEmail(email)}, verifiedAt,
).Int()
if err != nil {
return false, err
}
return res >= 0, nil // -1 means unknown email
}
func (s *RedisCredentialStore) UpdateHash(ctx context.Context, email, hash string) (bool, error) {
res, err := s.luaUpdHash.Run(ctx, s.client,
[]string{credKeyPrefix + NormalizeEmail(email)}, hash,
).Int()
if err != nil {
return false, err
}
return res == 1, nil
}
func (s *RedisCredentialStore) Delete(ctx context.Context, email string) (bool, error) {
key := credKeyPrefix + NormalizeEmail(email)
res, err := s.client.Del(ctx, key).Result()
if err != nil {
return false, err
}
return res > 0, nil
}
// ---------------------------------------------------------------------------
// RedisLoginLimiter
// ---------------------------------------------------------------------------
// RedisLoginLimiter is the shared-storage counterpart of LoginLimiter: the
// failure counter and lockout marker live in Redis with native TTLs, so the
// window/lockout are enforced across all replicas. It fails open (allows the
// attempt) on a Redis error so an outage degrades to "no rate limiting" rather
// than locking every customer out.
type RedisLoginLimiter struct {
client *redis.Client
luaRecord *redis.Script
max int
window time.Duration
}
// recordFailureScript increments the failure counter (setting the window TTL on
// the first failure) and, once it reaches max, writes a lockout marker with the
// same TTL.
const recordFailureScript = `
local cnt = redis.call('INCR', KEYS[1])
if cnt == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
if cnt >= tonumber(ARGV[1]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[2]) end
return cnt`
// NewRedisLoginLimiter builds a Redis-backed limiter. Zero/negative values fall
// back to 5 failures per 15 minutes (matching the in-memory default).
func NewRedisLoginLimiter(client *redis.Client, max int, window time.Duration) *RedisLoginLimiter {
if max <= 0 {
max = 5
}
if window <= 0 {
window = 15 * time.Minute
}
return &RedisLoginLimiter{
client: client,
luaRecord: redis.NewScript(recordFailureScript),
max: max,
window: window,
}
}
func (l *RedisLoginLimiter) Allowed(ctx context.Context, key string) (bool, time.Duration) {
ttl, err := l.client.PTTL(ctx, lockKeyPrefix+key).Result()
if err != nil {
log.Printf("customerauth: redis limiter Allowed(%s): %v", key, err)
return true, 0 // fail open
}
if ttl > 0 {
return false, ttl
}
return true, 0
}
func (l *RedisLoginLimiter) RecordFailure(ctx context.Context, key string) {
if err := l.luaRecord.Run(ctx, l.client,
[]string{failKeyPrefix + key, lockKeyPrefix + key},
strconv.Itoa(l.max), strconv.Itoa(int(l.window.Seconds())),
).Err(); err != nil {
log.Printf("customerauth: redis limiter RecordFailure(%s): %v", key, err)
}
}
func (l *RedisLoginLimiter) Reset(ctx context.Context, key string) {
if err := l.client.Del(ctx, failKeyPrefix+key, lockKeyPrefix+key).Err(); err != nil {
log.Printf("customerauth: redis limiter Reset(%s): %v", key, err)
}
}
+217 -10
View File
@@ -3,8 +3,11 @@ package customerauth
import (
"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
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 {
+75 -3
View File
@@ -1,6 +1,7 @@
package customerauth
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -14,6 +15,27 @@ 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.
@@ -22,6 +44,7 @@ type Record struct {
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))
+62
View File
@@ -0,0 +1,62 @@
package customerauth
import (
"crypto/hmac"
"encoding/base64"
"strconv"
"strings"
"time"
)
// Purpose-scoped tokens (email verification, password reset) reuse the session
// Signer's HMAC secret but bind a purpose + subject, so a token minted for one
// flow cannot be replayed in another. Format:
//
// base64url(<purpose> NUL <subject> NUL <expUnix>) "." base64url(hmac)
//
// subject is the normalized email. Like session tokens these are signed, not
// encrypted: they carry no secret, only a purpose, an email and an expiry, and
// the HMAC makes them tamper-evident.
const (
purposeVerifyEmail = "verify"
purposePasswordReset = "reset"
)
// IssuePurpose returns a signed, purpose-bound token for subject that expires
// after ttl.
func (s *Signer) IssuePurpose(purpose, subject string, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
payload := purpose + "\x00" + subject + "\x00" + strconv.FormatInt(exp, 10)
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
return b + "." + s.sign(b)
}
// ParsePurpose verifies a purpose token and returns its subject. It requires the
// embedded purpose to match want and the token to be unexpired, returning
// ErrInvalidToken for a bad signature/format/purpose and ErrExpiredToken when
// the token has expired.
func (s *Signer) ParsePurpose(want, token string) (string, error) {
b, sig, ok := strings.Cut(token, ".")
if !ok || b == "" || sig == "" {
return "", ErrInvalidToken
}
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
return "", ErrInvalidToken
}
raw, err := base64.RawURLEncoding.DecodeString(b)
if err != nil {
return "", ErrInvalidToken
}
parts := strings.Split(string(raw), "\x00")
if len(parts) != 3 || parts[0] != want {
return "", ErrInvalidToken
}
exp, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return "", ErrInvalidToken
}
if time.Now().Unix() >= exp {
return "", ErrExpiredToken
}
return parts[1], nil
}
+3 -2
View File
@@ -554,7 +554,8 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
Name: name,
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
+62 -2
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
@@ -16,11 +17,17 @@ import (
// CustomerServer is the UCP REST adapter over the profile grain pool.
type CustomerServer struct {
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
// ---------------------------------------------------------------------------
+87 -10
View File
@@ -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")
}
}
+7 -3
View File
@@ -86,12 +86,13 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
//
// 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)
@@ -107,6 +108,8 @@ 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
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)
+5
View File
@@ -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.
-7
View File
@@ -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 != "" {
+7
View File
@@ -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
@@ -158,6 +160,7 @@ type Voucher struct {
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
+6
View File
@@ -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"
}
+4
View File
@@ -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) {
+1 -1
View File
@@ -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)
+58
View File
@@ -0,0 +1,58 @@
package cart
// TaxProvider computes taxes for orders and line items.
// It mirrors the PaymentProvider and ShippingProvider seam patterns: one
// interface, interchangeable implementations.
//
// All tax rates are expressed as raw percent (e.g. 25 = 25 %). This matches the
// OrderLine.tax_rate proto convention.
type TaxProvider interface {
// Name returns the provider name for identification (logging, metrics).
Name() string
// ComputeTax computes the tax portion from a gross (inc-VAT) total and tax
// rate. Both total and return value are in minor currency units (ore).
// taxRate is expressed as raw percent (e.g. 25 = 25 %).
//
// Formula: tax = total x rate / (100 + rate)
//
// When taxRate <= 0 the result is 0 (untaxed / zero-rated items).
ComputeTax(total int64, taxRate int) int64
// DefaultTaxRate returns the default VAT rate for a country, expressed as
// raw percent (e.g. 25 = 25 %). Used for deliveries and items where no
// explicit rate was recorded at add-to-cart time.
//
// Return 0 if the country is unknown or untaxed.
DefaultTaxRate(country string) int
}
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
// taxRate is raw percent (e.g. 25 = 25 %). When taxRate <= 0 the result is 0.
//
// This differs from GetTaxAmount which uses the CartItem convention
// (percent x 100, e.g. 2500 = 25.00 %) with formula total x rate / (10000 + rate).
func ComputeTax(total int64, taxRate int) int64 {
if taxRate <= 0 {
return 0
}
return total * int64(taxRate) / int64(100+taxRate)
}
// StaticTaxProvider is a stateless, always-available TaxProvider that simply
// runs the formula without any country-specific rates. Use for local dev,
// tests, and as a no-dependency default.
type StaticTaxProvider struct{}
var _ TaxProvider = (*StaticTaxProvider)(nil)
func NewStaticTaxProvider() *StaticTaxProvider { return &StaticTaxProvider{} }
func (p *StaticTaxProvider) Name() string { return "static" }
func (p *StaticTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// DefaultTaxRate returns 0 for any country (no awareness).
func (p *StaticTaxProvider) DefaultTaxRate(_ string) int { return 0 }
+55
View File
@@ -0,0 +1,55 @@
package cart
// NordicTaxProvider implements TaxProvider for the Nordic countries
// (SE, NO, DK, FI). It encodes the standard statutory VAT rates:
//
// SE: 25 % (standard), 12 % (food, hotels), 6 % (books, transport)
// NO: 25 % (standard), 15 % (food), 12 %
// DK: 25 % (standard)
// FI: 25 % (standard; actual rate is 25.5 % as of 2026)
//
// All rates are raw percent (25 = 25 %). Fractional rates (FI 25.5) are
// truncated to the nearest integer per the raw-percent convention.
//
// NordicTaxProvider does not make network calls — all logic is local math.
// An external provider (Avalara, TaxJar) can be added by implementing
// TaxProvider and switching on TAX_PROVIDER.
type NordicTaxProvider struct {
defaultCountry string
}
// NewNordicTaxProvider returns a NordicTaxProvider. defaultCountry is used
// when DefaultTaxRate is called with an empty country code; it defaults to
// "SE" if empty.
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
if defaultCountry == "" {
defaultCountry = "SE"
}
return &NordicTaxProvider{defaultCountry: defaultCountry}
}
var _ TaxProvider = (*NordicTaxProvider)(nil)
func (p *NordicTaxProvider) Name() string { return "nordic" }
func (p *NordicTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// nordicStandardRates maps country code → default standard VAT rate (raw percent).
var nordicStandardRates = map[string]int{
"SE": 25,
"NO": 25,
"DK": 25,
"FI": 25,
}
func (p *NordicTaxProvider) DefaultTaxRate(country string) int {
if country == "" {
country = p.defaultCountry
}
if rate, ok := nordicStandardRates[country]; ok {
return rate
}
return nordicStandardRates[p.defaultCountry]
}
+113
View File
@@ -0,0 +1,113 @@
package cart
import (
"testing"
)
func TestNordicTaxProvider_Name(t *testing.T) {
p := NewNordicTaxProvider("SE")
if got := p.Name(); got != "nordic" {
t.Errorf("Name() = %q, want %q", got, "nordic")
}
}
func TestNordicTaxProvider_ComputeTax(t *testing.T) {
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{0, 25, 0, "zero total"},
{1250, 25, 250, "25 % VAT on 1250"},
{1000, 20, 166, "20 % VAT on 1000"},
{1200, 25, 240, "25 % VAT on 1200"},
{25000, 25, 5000, "25 % VAT on 25000"},
{100, 10, 9, "10 % VAT on 100"},
{100, 100, 50, "100 % VAT on 100"},
{100, 0, 0, "zero-rate"},
{100, -1, 0, "negative rate -> zero"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
got := p.ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
func TestNordicTaxProvider_DefaultTaxRate(t *testing.T) {
tests := []struct {
country string
want int
desc string
}{
{"SE", 25, "Sweden"},
{"NO", 25, "Norway"},
{"DK", 25, "Denmark"},
{"FI", 25, "Finland"},
{"", 25, "empty -> default SE"},
{"DE", 25, "unknown -> fallback SE"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
got := p.DefaultTaxRate(tt.country)
if got != tt.want {
t.Errorf("DefaultTaxRate(%q) [%s] = %d; want %d", tt.country, tt.desc, got, tt.want)
}
}
}
func TestStaticTaxProvider_ComputeTax(t *testing.T) {
p := NewStaticTaxProvider()
tests := []struct {
total int64
taxRate int
want int64
}{
{1250, 25, 250},
{25000, 25, 5000},
{1000, 20, 166},
{0, 25, 0},
}
for _, tt := range tests {
got := p.ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) = %d; want %d", tt.total, tt.taxRate, got, tt.want)
}
}
}
func TestStaticTaxProvider_DefaultTaxRate(t *testing.T) {
p := NewStaticTaxProvider()
if got := p.DefaultTaxRate("SE"); got != 0 {
t.Errorf("StaticTaxProvider.DefaultTaxRate(\"SE\") = %d; want 0", got)
}
}
func TestComputeTax(t *testing.T) {
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{1250, 25, 250, "canonical: 25 %"},
{25000, 25, 5000, "25k with 25 %% -> 5k tax"},
{0, 25, 0, "zero total"},
}
for _, tt := range tests {
got := ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
func TestTaxProviderImplementsInterface(t *testing.T) {
var p TaxProvider = NewNordicTaxProvider("SE")
_ = p
p2 := NewStaticTaxProvider()
_ = p2
}
+36 -2
View File
@@ -2,7 +2,9 @@ package discovery
import (
"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,
}
}
+101
View File
@@ -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),
)
}
+12
View File
@@ -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"`
+30
View File
@@ -0,0 +1,30 @@
package order
import "git.k6n.net/mats/go-cart-actor/pkg/cart"
// TaxProvider computes taxes for orders and line items.
// The canonical definition is in pkg/cart; this is a re-export for
// backward compatibility.
type TaxProvider = cart.TaxProvider
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
// Re-exported from pkg/cart for backward compatibility.
func ComputeTax(total int64, taxRate int) int64 {
return cart.ComputeTax(total, taxRate)
}
// NordicTaxProvider implements TaxProvider for the Nordic countries.
// Re-exported from pkg/cart for backward compatibility.
type NordicTaxProvider = cart.NordicTaxProvider
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
return cart.NewNordicTaxProvider(defaultCountry)
}
// StaticTaxProvider is a stateless TaxProvider with no country awareness.
// Re-exported from pkg/cart for backward compatibility.
type StaticTaxProvider = cart.StaticTaxProvider
func NewStaticTaxProvider() *StaticTaxProvider {
return cart.NewStaticTaxProvider()
}
+34
View File
@@ -0,0 +1,34 @@
package order
import (
"testing"
)
// TestReExports verifies that the order package's type aliases and
// forwarders to pkg/cart compile and work correctly.
func TestReExports(t *testing.T) {
// Type alias: TaxProvider = cart.TaxProvider
var _ TaxProvider = NewNordicTaxProvider("SE")
var _ TaxProvider = NewStaticTaxProvider()
// NordicTaxProvider
p := NewNordicTaxProvider("SE")
if p.Name() != "nordic" {
t.Errorf("Name = %q", p.Name())
}
if got := p.DefaultTaxRate("SE"); got != 25 {
t.Errorf("DefaultTaxRate(SE) = %d, want 25", got)
}
if got := ComputeTax(1250, 25); got != 250 {
t.Errorf("ComputeTax(1250, 25) = %d, want 250", got)
}
// StaticTaxProvider
s := NewStaticTaxProvider()
if s.Name() != "static" {
t.Errorf("Name = %q", s.Name())
}
if got := s.DefaultTaxRate("SE"); got != 0 {
t.Errorf("DefaultTaxRate(SE) = %d, want 0", got)
}
}
+1
View File
@@ -16,6 +16,7 @@ func NewProfileMutationRegistry() actor.MutationRegistry {
actor.NewMutation(HandleLinkCart),
actor.NewMutation(HandleLinkCheckout),
actor.NewMutation(HandleLinkOrder),
actor.NewMutation(HandleAnonymizeProfile),
)
return reg
}
+20
View File
@@ -0,0 +1,20 @@
package profile
import (
"fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
)
// HandleAnonymizeProfile wipes all PII from the customer profile.
func HandleAnonymizeProfile(p *ProfileGrain, m *messages.AnonymizeProfile) error {
if m == nil {
return fmt.Errorf("AnonymizeProfile: nil payload")
}
p.Name = ""
p.Email = ""
p.Phone = ""
p.AvatarUrl = ""
p.Addresses = nil // Clear all addresses containing PII
return nil
}
+367 -5
View File
@@ -2,6 +2,8 @@ package promotions
import (
"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
}
}
+101
View File
@@ -146,3 +146,104 @@ func TestApplyResultsRecordsEffects(t *testing.T) {
}
})
}
func TestBuyXGetYEffect(t *testing.T) {
rule := PromotionRule{
ID: "buy2get1",
Name: "Buy 2 Get 1 Free",
Status: StatusActive,
Actions: []Action{
{
ID: "buy2get1-action",
Type: ActionBuyXGetY,
Config: map[string]interface{}{
"buy": 2.0,
"get": 1.0,
"discount": 100.0,
},
},
},
}
svc := NewPromotionService(nil)
// Cart with 3 items of price 1000 each. Buy 2 get 1 free -> 1 is free -> 1000 discount.
g := cart.NewCartGrain(1, timeZero())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "SKU1", Quantity: 3, Price: *cart.NewPriceFromIncVat(1000, 25)},
}
g.UpdateTotals()
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
if got := g.TotalDiscount.IncVat; got != 1000 {
t.Errorf("discount = %d, want 1000", got)
}
if got := g.TotalPrice.IncVat; got != 2000 {
t.Errorf("total price = %d, want 2000", got)
}
}
func TestBundleDiscountEffect(t *testing.T) {
rule := PromotionRule{
ID: "bundle-deal",
Name: "Bundle Deal",
Status: StatusActive,
Actions: []Action{
{
ID: "bundle-action",
Type: ActionBundleDiscount,
BundleConfig: &BundleConfig{
Containers: []BundleContainer{
{
ID: "shoes",
Name: "Shoes",
Quantity: 1,
SelectionType: "any",
QualifyingRules: BundleQualifyingRules{
Type: "category",
Value: "shoes",
},
},
{
ID: "socks",
Name: "Socks",
Quantity: 2,
SelectionType: "any",
QualifyingRules: BundleQualifyingRules{
Type: "category",
Value: "socks",
},
},
},
Pricing: BundlePricing{
Type: "fixed_price",
Value: 40.0, // 40 kr -> 4000 ore
},
RequireAllContainers: true,
},
},
},
}
svc := NewPromotionService(nil)
// Cart with 1 shoe (5000) and 2 socks (1000 each). Total original = 7000. Bundle fixed price is 4000. Discount = 3000.
g := cart.NewCartGrain(1, timeZero())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "shoe-1", Quantity: 1, Price: *cart.NewPriceFromIncVat(5000, 25), Meta: &cart.ItemMeta{Category: "shoes"}},
{Id: 2, Sku: "socks-1", Quantity: 2, Price: *cart.NewPriceFromIncVat(1000, 25), Meta: &cart.ItemMeta{Category: "socks"}},
}
g.UpdateTotals()
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
if got := g.TotalDiscount.IncVat; got != 3000 {
t.Errorf("discount = %d, want 3000", got)
}
if got := g.TotalPrice.IncVat; got != 4000 {
t.Errorf("total price = %d, want 4000", got)
}
}
+34 -2
View File
@@ -42,6 +42,7 @@ type PromotionEvalContext struct {
CustomerLifetimeValue float64
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
+39
View File
@@ -442,6 +442,45 @@ func TestDateRangeCondition(t *testing.T) {
}
}
func TestCouponCodeCondition(t *testing.T) {
rule := PromotionRule{
ID: "coupon-promo",
Name: "Coupon Promo",
Status: StatusActive,
Conditions: Conditions{
BaseCondition{
ID: "c",
Type: CondCouponCode,
Operator: OpEquals,
Value: cvString("SAVE50"),
},
},
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
}
svc := NewPromotionService(nil)
// Context with matching coupon code
ctxMatched := &PromotionEvalContext{
CouponCodes: []string{"SAVE50"},
Now: time.Now(),
}
resMatched := svc.EvaluateRule(rule, ctxMatched)
if !resMatched.Applicable {
t.Errorf("expected coupon code rule to apply with SAVE50")
}
// Context without matching coupon code
ctxUnmatched := &PromotionEvalContext{
CouponCodes: []string{"SAVE20"},
Now: time.Now(),
}
resUnmatched := svc.EvaluateRule(rule, ctxUnmatched)
if resUnmatched.Applicable {
t.Errorf("expected coupon code rule NOT to apply with SAVE20")
}
}
// --- Utilities -------------------------------------------------------------
func ptr(s string) *string { return &s }
+15 -4
View File
@@ -52,7 +52,7 @@ type EvaluateResponse struct {
// Evaluate runs rules against a synthetic cart built from req and returns the
// 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
+9
View File
@@ -133,6 +133,15 @@ func (s *Server) initialize(params json.RawMessage) map[string]any {
}
}
// defaultVatRate returns the default VAT rate from the eval service's
// configured tax provider, falling back to 25 if none is set.
func (s *Server) defaultVatRate() float32 {
if s.eval == nil || s.eval.DefaultTaxProvider == nil {
return 25
}
return float32(s.eval.DefaultTaxProvider.DefaultTaxRate(""))
}
func isBatch(body []byte) bool {
for _, b := range body {
switch b {
+2 -2
View File
@@ -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))
+9 -5
View File
@@ -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.
+1
View File
@@ -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
)
-3
View File
@@ -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 {
+88
View File
@@ -0,0 +1,88 @@
// Package telemetry holds shared OpenTelemetry setup helpers used by the
// service main packages (cart, checkout, profile, …).
package telemetry
import (
"context"
"math/rand/v2"
"os"
"strconv"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/sdk/log"
)
// NewLoggerProvider builds the OTLP log provider from the environment, or returns
// (nil, nil) when logs are disabled — in which case the caller MUST skip
// global.SetLoggerProvider and the shutdown registration.
//
// Env:
//
// OTEL_LOGS_ENABLED "1"/"true"/"yes"/"on" to enable the OTLP log pipeline.
// Default OFF: the batch processor clones its record ring
// on every export, which profiling showed to be the
// dominant heap allocator in these services. Traces and
// metrics are always on regardless of this flag.
// OTEL_LOGS_SAMPLE_RATIO fraction of records to export, 0.01.0 (default 1.0).
// e.g. 0.1 keeps ~10% of logs. Dropped records never reach
// the batch ring, so allocation churn falls ~proportionally.
func NewLoggerProvider(ctx context.Context) (*log.LoggerProvider, error) {
if !logsEnabled() {
return nil, nil
}
exporter, err := otlploggrpc.New(ctx)
if err != nil {
return nil, err
}
var proc log.Processor = log.NewBatchProcessor(exporter)
if ratio := sampleRatio(); ratio < 1.0 {
proc = &samplingProcessor{next: proc, ratio: ratio}
}
return log.NewLoggerProvider(log.WithProcessor(proc)), nil
}
func logsEnabled() bool {
switch os.Getenv("OTEL_LOGS_ENABLED") {
case "1", "true", "TRUE", "True", "yes", "on":
return true
default:
return false
}
}
func sampleRatio() float64 {
v := os.Getenv("OTEL_LOGS_SAMPLE_RATIO")
if v == "" {
return 1.0
}
r, err := strconv.ParseFloat(v, 64)
if err != nil || r < 0 {
return 1.0
}
if r > 1 {
return 1.0
}
return r
}
// samplingProcessor forwards a random `ratio` fraction of records to the wrapped
// processor and drops the rest before they reach the (allocation-heavy) batch
// ring. This is head sampling: cheap, stateless, and per-record independent.
type samplingProcessor struct {
next log.Processor
ratio float64
}
func (s *samplingProcessor) Enabled(ctx context.Context, param log.EnabledParameters) bool {
return s.next.Enabled(ctx, param)
}
func (s *samplingProcessor) OnEmit(ctx context.Context, record *log.Record) error {
if rand.Float64() >= s.ratio {
return nil // dropped by sampling
}
return s.next.OnEmit(ctx, record)
}
func (s *samplingProcessor) Shutdown(ctx context.Context) error { return s.next.Shutdown(ctx) }
func (s *samplingProcessor) ForceFlush(ctx context.Context) error { return s.next.ForceFlush(ctx) }
+19 -30
View File
@@ -1,4 +1,4 @@
package main
package telemetry
import (
"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
}
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
}
+2
View File
@@ -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<string, string> custom_fields = 28;
bool inventory_tracked = 29;
bool drop_ship = 30;
}
message RemoveItem { uint32 Id = 1; }
+148 -127
View File
@@ -94,6 +94,8 @@ type AddItem struct {
// custom_fields holds optional user-supplied input fields for this line
// (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"`
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
}
@@ -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 (
+21
View File
@@ -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;
}
+195 -13
View File
@@ -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,
},
+5
View File
@@ -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;
}
}
+254 -192
View File
@@ -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,159 +842,167 @@ 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{
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
@@ -952,9 +1011,10 @@ var file_proto_profile_proto_goTypes = []any{
(*LinkCart)(nil), // 5: profile_messages.LinkCart
(*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout
(*LinkOrder)(nil), // 7: profile_messages.LinkOrder
(*Mutation)(nil), // 8: profile_messages.Mutation
(*AnonymizeProfile)(nil), // 8: profile_messages.AnonymizeProfile
(*Mutation)(nil), // 9: profile_messages.Mutation
}
var file_proto_profile_proto_depIdxs = []int32{
var file_profile_proto_depIdxs = []int32{
0, // 0: profile_messages.AddAddress.address:type_name -> profile_messages.Address
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
}