cart and checkout
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -193,6 +193,18 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
|
||||
logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway",
|
||||
"err", err, "checkoutId", grain.Id.String())
|
||||
invStatus = "failed"
|
||||
} else {
|
||||
// Commit step: successfully decremented stock permanently, now release the temporary cart reservations
|
||||
if s.reservationService != nil {
|
||||
for _, item := range grain.CartState.Items {
|
||||
if item == nil || !shouldTrackInventory(item) {
|
||||
continue
|
||||
}
|
||||
if err := s.reservationService.ReleaseForCart(r.Context(), inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
|
||||
logger.WarnContext(r.Context(), "klarna push: failed to release cart reservation", "sku", item.Sku, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
|
||||
Id: grain.Id.String(),
|
||||
@@ -209,22 +221,12 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
|
||||
// ── Create the event-sourced order grain (dual-write with AMQP) ────
|
||||
if s.orderClient != nil {
|
||||
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
|
||||
// Non-fatal: the checkout grain is updated, the order can be
|
||||
// created on retry (idempotency key guards against duplicates).
|
||||
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
|
||||
"err", orderErr, "checkoutId", grain.Id.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dual-write: AMQP order-queue for legacy consumers ───────────────
|
||||
if s.orderHandler != nil {
|
||||
legacyBody, _ := buildLegacyOrderJSON(grain, order, "klarna", orderId)
|
||||
if pubErr := s.orderHandler.OrderCompleted(legacyBody); pubErr != nil {
|
||||
logger.WarnContext(r.Context(), "order-queue publish failed", "err", pubErr)
|
||||
}
|
||||
// ── Create the event-sourced order grain (HTTP with AMQP fallback) ────
|
||||
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
|
||||
// Non-fatal: the checkout grain is updated, the order can be
|
||||
// created on retry (idempotency key guards against duplicates).
|
||||
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
|
||||
"err", orderErr, "checkoutId", grain.Id.String())
|
||||
}
|
||||
|
||||
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
||||
@@ -264,59 +266,7 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
|
||||
})
|
||||
}
|
||||
|
||||
// buildLegacyOrderJSON builds the go-order-manager compatible JSON for AMQP
|
||||
// dual-write, so existing downstream consumers on the order-queue still receive
|
||||
// order events during the migration period.
|
||||
func buildLegacyOrderJSON(grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string, orderId string) ([]byte, error) {
|
||||
if grain.CartState == nil {
|
||||
return nil, fmt.Errorf("buildLegacyOrderJSON: checkout %s has no cart state", grain.Id)
|
||||
}
|
||||
type legacyLine struct {
|
||||
Reference string `json:"reference"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice int `json:"unit_price"`
|
||||
TaxRate int `json:"tax_rate"`
|
||||
TotalAmount int `json:"total_amount"`
|
||||
TotalTaxAmount int `json:"total_tax_amount"`
|
||||
}
|
||||
type legacyOrder struct {
|
||||
ID string `json:"order_id"`
|
||||
PurchaseCountry string `json:"purchase_country"`
|
||||
PurchaseCurrency string `json:"purchase_currency"`
|
||||
Locale string `json:"locale"`
|
||||
OrderAmount int `json:"order_amount"`
|
||||
OrderTaxAmount int `json:"order_tax_amount"`
|
||||
OrderLines []legacyLine `json:"order_lines"`
|
||||
}
|
||||
|
||||
lines := make([]legacyLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
||||
for _, it := range grain.CartState.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, legacyLine{
|
||||
Reference: it.Sku,
|
||||
Name: it.Meta.Name,
|
||||
Quantity: int(it.Quantity),
|
||||
UnitPrice: int(it.Price.IncVat),
|
||||
TaxRate: it.Tax,
|
||||
TotalAmount: int(it.TotalPrice.IncVat),
|
||||
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
||||
})
|
||||
}
|
||||
|
||||
lo := legacyOrder{
|
||||
ID: orderId,
|
||||
PurchaseCountry: klarnaOrder.PurchaseCountry,
|
||||
PurchaseCurrency: klarnaOrder.PurchaseCurrency,
|
||||
Locale: klarnaOrder.Locale,
|
||||
OrderAmount: klarnaOrder.OrderAmount,
|
||||
OrderTaxAmount: klarnaOrder.OrderTaxAmount,
|
||||
OrderLines: lines,
|
||||
}
|
||||
return json.Marshal(lo)
|
||||
}
|
||||
|
||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||
if item.StoreId == nil || *item.StoreId == "" {
|
||||
@@ -325,10 +275,20 @@ func getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||
return inventory.LocationID(*item.StoreId)
|
||||
}
|
||||
|
||||
func shouldTrackInventory(item *cart.CartItem) bool {
|
||||
if item.DropShip {
|
||||
return false
|
||||
}
|
||||
if item.InventoryTracked {
|
||||
return true
|
||||
}
|
||||
return item.Cgm == "55010"
|
||||
}
|
||||
|
||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
||||
var requests []inventory.ReserveRequest
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
if item == nil || !shouldTrackInventory(item) {
|
||||
continue
|
||||
}
|
||||
requests = append(requests, inventory.ReserveRequest{
|
||||
|
||||
+40
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
@@ -27,6 +28,16 @@ type settledPayment struct {
|
||||
// selections into from-checkout order lines. Shared by every provider so the
|
||||
// order payload is built the same way no matter who settled the payment.
|
||||
func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
||||
hasFreeShipping := false
|
||||
if grain.CartState != nil {
|
||||
for _, ap := range grain.CartState.AppliedPromotions {
|
||||
if ap.Type == "free_shipping" && !ap.Pending {
|
||||
hasFreeShipping = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
||||
for _, it := range grain.CartState.Items {
|
||||
if it == nil {
|
||||
@@ -38,11 +49,19 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
||||
Name: it.Meta.Name,
|
||||
Quantity: int32(it.Quantity),
|
||||
UnitPrice: it.Price.IncVat,
|
||||
TaxRate: int32(it.Tax),
|
||||
// CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
|
||||
TaxRate: int32(it.Tax / 100),
|
||||
})
|
||||
}
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
unitPrice := d.Price.IncVat
|
||||
if hasFreeShipping {
|
||||
unitPrice = 0
|
||||
}
|
||||
if unitPrice <= 0 && !hasFreeShipping {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, OrderLine{
|
||||
@@ -50,10 +69,35 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
||||
Sku: d.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: d.Price.IncVat,
|
||||
TaxRate: 2500,
|
||||
UnitPrice: unitPrice,
|
||||
TaxRate: 25,
|
||||
})
|
||||
}
|
||||
|
||||
// Reflected applied promotions as negative discount lines
|
||||
if grain.CartState != nil {
|
||||
for _, ap := range grain.CartState.AppliedPromotions {
|
||||
if ap.Pending {
|
||||
continue
|
||||
}
|
||||
discountVal := int64(0)
|
||||
if ap.Discount != nil {
|
||||
discountVal = ap.Discount.IncVat
|
||||
}
|
||||
if discountVal <= 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, OrderLine{
|
||||
Reference: "promo-" + ap.PromotionId,
|
||||
Sku: "promo-" + ap.PromotionId,
|
||||
Name: "Promotion: " + ap.Name,
|
||||
Quantity: 1,
|
||||
UnitPrice: -discountVal,
|
||||
TaxRate: 25, // default standard tax rate for promotion discount
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -64,8 +108,10 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
||||
//
|
||||
// It is idempotent: the order service dedupes on "checkout-{checkoutId}", so
|
||||
// repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the
|
||||
// existing order rather than creating a duplicate. Callers should treat a
|
||||
// non-nil error as non-fatal and retry on the next callback.
|
||||
// existing order rather than creating a duplicate.
|
||||
//
|
||||
// If the HTTP call fails or is not configured, it falls back to publishing the
|
||||
// exact same payload to RabbitMQ via s.orderHandler.OrderCompleted(body).
|
||||
func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) {
|
||||
if grain.CartState == nil {
|
||||
return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id)
|
||||
@@ -96,15 +142,41 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
|
||||
req.Payment.Reference = pay.Reference
|
||||
req.Payment.Amount = pay.Amount
|
||||
|
||||
result, err := s.orderClient.CreateOrder(ctx, req, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var createErr error
|
||||
if s.orderClient != nil {
|
||||
result, err := s.orderClient.CreateOrder(ctx, req, "")
|
||||
if err == nil {
|
||||
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
|
||||
OrderId: result.OrderId,
|
||||
Status: "completed",
|
||||
CreatedAt: timestamppb.Now(),
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
createErr = err
|
||||
logger.WarnContext(ctx, "from-checkout HTTP call failed; falling back to AMQP", "err", err, "checkoutId", grain.Id.String())
|
||||
}
|
||||
|
||||
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
|
||||
OrderId: result.OrderId,
|
||||
Status: "completed",
|
||||
CreatedAt: timestamppb.Now(),
|
||||
})
|
||||
return result, nil
|
||||
// Fallback to AMQP
|
||||
if s.orderHandler != nil {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal order fallback: %w", err)
|
||||
}
|
||||
if pubErr := s.orderHandler.OrderCompleted(body); pubErr != nil {
|
||||
if createErr != nil {
|
||||
return nil, fmt.Errorf("AMQP fallback failed: %w (HTTP error: %v)", pubErr, createErr)
|
||||
}
|
||||
return nil, fmt.Errorf("AMQP fallback failed: %w", pubErr)
|
||||
}
|
||||
logger.InfoContext(ctx, "order request published to AMQP successfully as fallback", "checkoutId", grain.Id.String())
|
||||
return &CreateOrderResult{
|
||||
OrderId: "queued-" + grain.Id.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
return nil, fmt.Errorf("neither order client nor AMQP order handler configured")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -51,7 +51,9 @@ type CheckoutPoolServer struct {
|
||||
cartClient *CartClient
|
||||
orderClient *OrderClient
|
||||
orderHandler *AmqpOrderHandler
|
||||
inventoryService *inventory.RedisInventoryService
|
||||
inventoryService *inventory.RedisInventoryService
|
||||
reservationService *inventory.RedisCartReservationService
|
||||
taxProvider cart.TaxProvider
|
||||
}
|
||||
|
||||
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer {
|
||||
@@ -207,6 +209,10 @@ func (s *CheckoutPoolServer) CancelPaymentHandler(w http.ResponseWriter, r *http
|
||||
return err
|
||||
}
|
||||
|
||||
if grain, gerr := s.Get(r.Context(), uint64(checkoutId)); gerr == nil && grain != nil {
|
||||
s.releaseCartReservations(r.Context(), grain)
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
@@ -295,7 +301,7 @@ func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *chec
|
||||
|
||||
meta := GetCheckoutMetaFromRequest(r)
|
||||
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta, s.taxProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -431,7 +437,7 @@ func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.
|
||||
switch payload.Provider {
|
||||
case "adyen":
|
||||
meta := GetCheckoutMetaFromRequest(r)
|
||||
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
|
||||
sessionData, err := BuildAdyenCheckoutSession(grain, meta, s.taxProvider)
|
||||
if err != nil {
|
||||
logger.Error("unable to build adyen session", "error", err)
|
||||
return err
|
||||
@@ -536,10 +542,10 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
||||
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
|
||||
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
|
||||
handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler)))
|
||||
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
|
||||
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
||||
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
||||
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
||||
handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
|
||||
handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
||||
handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
||||
handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
||||
|
||||
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
|
||||
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
|
||||
@@ -547,3 +553,17 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
||||
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
|
||||
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) releaseCartReservations(ctx context.Context, grain *checkout.CheckoutGrain) {
|
||||
if s.reservationService == nil || grain.CartState == nil {
|
||||
return
|
||||
}
|
||||
for _, item := range grain.CartState.Items {
|
||||
if item == nil || !shouldTrackInventory(item) {
|
||||
continue
|
||||
}
|
||||
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
|
||||
logger.WarnContext(ctx, "failed to release cart reservation", "sku", item.Sku, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -3,14 +3,19 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
)
|
||||
|
||||
func testApplier(t *testing.T) *orderedApplier {
|
||||
func testServer(t *testing.T) *server {
|
||||
t.Helper()
|
||||
reg := actor.NewMutationRegistry()
|
||||
order.RegisterMutations(reg)
|
||||
@@ -32,23 +37,56 @@ func testApplier(t *testing.T) *orderedApplier {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return &orderedApplier{pool: pool, storage: storage}
|
||||
|
||||
applier := &orderedApplier{pool: pool, storage: storage}
|
||||
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
|
||||
|
||||
freg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(freg)
|
||||
order.RegisterFlowActions(freg, applier, provider)
|
||||
order.RegisterEmitHook(freg, nil)
|
||||
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
def, err := order.EmbeddedFlow("place-and-pay")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idem, err := idempotency.Open(filepath.Join(t.TempDir(), "idem.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &server{
|
||||
pool: pool, applier: applier, storage: storage, reg: reg,
|
||||
engine: engine, freg: freg,
|
||||
flows: &flowStore{defs: map[string]*flow.Definition{def.Name: def}},
|
||||
provider: provider, taxProvider: order.NewStaticTaxProvider(),
|
||||
defaultFlow: def.Name, idem: idem, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestLegacyOrder(t *testing.T) {
|
||||
app := testApplier(t)
|
||||
func TestIngestOrderFromQueue(t *testing.T) {
|
||||
s := testServer(t)
|
||||
ctx := context.Background()
|
||||
body := []byte(`{
|
||||
"order_id":"K-1","purchase_currency":"SEK","purchase_country":"se","locale":"sv-SE",
|
||||
"order_amount":15000,"order_tax_amount":3000,
|
||||
"order_lines":[{"reference":"l1","name":"Widget","quantity":1,"unit_price":15000,"tax_rate":25,"total_amount":15000,"total_tax_amount":3000}]
|
||||
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
|
||||
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
|
||||
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
|
||||
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
|
||||
}`)
|
||||
|
||||
if err := ingestLegacyOrder(ctx, app, body); err != nil {
|
||||
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
id := legacyOrderID("K-1")
|
||||
g, err := app.Get(ctx, id)
|
||||
|
||||
// Retrieve the created order grain. The new order ID is recorded in idempotency.Store
|
||||
id, ok := s.idem.Get("checkout-K-1")
|
||||
if !ok {
|
||||
t.Fatalf("expected order ID to be stored in idempotency cache")
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -60,10 +98,10 @@ func TestIngestLegacyOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
// Redelivery must be idempotent — no double capture, no new payment.
|
||||
if err := ingestLegacyOrder(ctx, app, body); err != nil {
|
||||
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||
t.Fatalf("re-ingest: %v", err)
|
||||
}
|
||||
g2, _ := app.Get(ctx, id)
|
||||
g2, _ := s.applier.Get(ctx, id)
|
||||
if g2.CapturedAmount != 15000 {
|
||||
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
|
||||
}
|
||||
|
||||
+146
-4
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
type fromCheckoutReq struct {
|
||||
CheckoutId string `json:"checkoutId"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Flow string `json:"flow,omitempty"`
|
||||
Flow string `json:"flow,omitempty"`
|
||||
|
||||
CartId string `json:"cartId"`
|
||||
Currency string `json:"currency"`
|
||||
@@ -55,101 +56,95 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── Idempotency check ─────────────────────────────────────────────
|
||||
// Lock the key for the whole check→create→record sequence so a concurrent
|
||||
// retry with the same key can't race past the check and create a second
|
||||
// order. The store is durable, so this also holds across a restart.
|
||||
ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if exists {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"orderId": order.OrderId(ordID).String(),
|
||||
"order": grain,
|
||||
"existing": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusCreated
|
||||
if runErr != nil {
|
||||
status = http.StatusPaymentRequired
|
||||
}
|
||||
|
||||
writeJSON(w, status, map[string]any{
|
||||
"orderId": order.OrderId(ordID).String(),
|
||||
"flow": flowRes,
|
||||
"order": grain,
|
||||
})
|
||||
}
|
||||
|
||||
// createOrderFromCheckoutInternal runs the core idempotent checkout-to-order logic.
|
||||
// It returns (orderID, flowResult, grain, exists, runErr, err).
|
||||
// - exists is true if the order already existed (idempotency hit).
|
||||
// - runErr is the flow run error (payment flow failed, e.g. compensating was run).
|
||||
// - err is a structural/storage error.
|
||||
func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromCheckoutReq) (uint64, *flow.Result, *order.OrderGrain, bool, error, error) {
|
||||
if req.IdempotencyKey != "" {
|
||||
unlock := s.idem.Lock(req.IdempotencyKey)
|
||||
defer unlock()
|
||||
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
|
||||
// The order already exists — return it.
|
||||
g, err := s.applier.Get(r.Context(), existingID)
|
||||
g, err := s.applier.Get(ctx, existingID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err))
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err)
|
||||
}
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"orderId": order.OrderId(existingID).String(),
|
||||
"order": g,
|
||||
"existing": true,
|
||||
})
|
||||
return
|
||||
return existingID, nil, g, true, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create the order grain ────────────────────────────────────────
|
||||
id, err := order.NewOrderId()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("new order id: %w", err))
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err)
|
||||
}
|
||||
ordID := uint64(id)
|
||||
|
||||
// Build PlaceOrder from the same lineReq format the direct checkout uses.
|
||||
po := buildFromCheckoutPlaceOrder(id, &req)
|
||||
|
||||
// Build a passthrough provider for the externally-settled payment.
|
||||
po := buildFromCheckoutPlaceOrder(id, req)
|
||||
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
|
||||
|
||||
// Run the place-and-pay flow with the passthrough provider. Since the
|
||||
// payment is already settled, this records the authorization and capture
|
||||
// without any external call.
|
||||
flowName := req.Flow
|
||||
if flowName == "" {
|
||||
flowName = s.defaultFlow
|
||||
}
|
||||
def, ok := s.flows.get(flowName)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName))
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
|
||||
}
|
||||
|
||||
// Create a per-request flow registry so the authorize/capture actions use
|
||||
// the passthrough provider for this specific order. RegisterFlowActions
|
||||
// registers all order lifecycle actions, overriding the default provider.
|
||||
freg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(freg)
|
||||
order.RegisterFlowActions(freg, s.applier, provider)
|
||||
order.RegisterEmitHook(freg, nil)
|
||||
|
||||
engine := flow.NewEngine(freg, s.logger)
|
||||
|
||||
st := flow.NewState(ordID, s.logger)
|
||||
st.Vars[order.PlaceOrderVar] = po
|
||||
|
||||
res, runErr := engine.Run(r.Context(), def, st)
|
||||
grain, getErr := s.applier.Get(r.Context(), ordID)
|
||||
res, runErr := engine.Run(ctx, def, st)
|
||||
grain, getErr := s.applier.Get(ctx, ordID)
|
||||
if getErr != nil {
|
||||
writeErr(w, http.StatusInternalServerError, getErr)
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr)
|
||||
}
|
||||
|
||||
// On failure the flow should have compensated (voided + cancelled). Return
|
||||
// the failed order with the flow trace so the checkout service can decide
|
||||
// how to proceed (e.g. alert operator, retry with a new idempotency key).
|
||||
status := http.StatusCreated
|
||||
if runErr != nil {
|
||||
status = http.StatusPaymentRequired
|
||||
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
|
||||
}
|
||||
|
||||
// Record the idempotency key durably only on success, so retries (incl. after
|
||||
// a restart) return the captured order. A failed flow (402) is deliberately
|
||||
// NOT recorded: the failed grain is terminal, and a retry with the same key
|
||||
// should re-attempt rather than be handed back a dead order as a 409 hit
|
||||
// (which the checkout client treats as a successful, existing order).
|
||||
if req.IdempotencyKey != "" && runErr == nil {
|
||||
if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil {
|
||||
s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, status, map[string]any{
|
||||
"orderId": id.String(),
|
||||
"flow": res,
|
||||
"order": grain,
|
||||
})
|
||||
return ordID, res, grain, false, runErr, nil
|
||||
}
|
||||
|
||||
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
|
||||
@@ -169,11 +164,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
|
||||
var total, totalTax int64
|
||||
for _, l := range req.Lines {
|
||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
||||
var lineTax int64
|
||||
if l.TaxRate > 0 {
|
||||
// inc-vat -> tax portion = total * rate / (100 + rate)
|
||||
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
|
||||
}
|
||||
lineTax := order.ComputeTax(lineTotal, int(l.TaxRate))
|
||||
total += lineTotal
|
||||
totalTax += lineTax
|
||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||
|
||||
+30
-11
@@ -41,6 +41,7 @@ type server struct {
|
||||
freg *flow.Registry
|
||||
flows *flowStore
|
||||
provider order.PaymentProvider
|
||||
taxProvider order.TaxProvider
|
||||
defaultFlow string
|
||||
dataDir string
|
||||
idem *idempotency.Store
|
||||
@@ -134,9 +135,12 @@ func main() {
|
||||
log.Fatalf("open idempotency store: %v", err)
|
||||
}
|
||||
|
||||
taxProvider := selectTaxProvider()
|
||||
|
||||
s := &server{
|
||||
pool: pool, applier: applier, storage: storage, reg: reg,
|
||||
engine: engine, freg: freg, flows: flows, provider: provider,
|
||||
taxProvider: taxProvider,
|
||||
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
|
||||
}
|
||||
|
||||
@@ -150,12 +154,14 @@ func main() {
|
||||
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
|
||||
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
|
||||
|
||||
// Post-purchase lifecycle (the grain already supports these transitions).
|
||||
// Post-purchase lifecycle (the grain already supports these transitions).
|
||||
mux.HandleFunc("POST /api/orders/{id}/fulfillments", s.handleFulfill)
|
||||
mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete)
|
||||
mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel)
|
||||
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
|
||||
mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
|
||||
mux.HandleFunc("POST /api/orders/{id}/exchanges", s.handleExchange)
|
||||
mux.HandleFunc("POST /api/orders/{id}/edit", s.handleEditDetails)
|
||||
|
||||
// UCP REST adapter — Universal Commerce Protocol order lifecycle.
|
||||
orderUCP := ucp.OrderHandler(s.applier)
|
||||
@@ -183,10 +189,9 @@ func main() {
|
||||
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
|
||||
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
|
||||
|
||||
// Ingest legacy checkout orders (Klarna/Adyen → order-queue) so this service
|
||||
// is the single source of orders, superseding go-order-manager.
|
||||
// Ingest checkout orders from order-queue (Klarna/Adyen fallback).
|
||||
if amqpURL != "" {
|
||||
startLegacyIngest(context.Background(), amqpURL, applier, logger)
|
||||
startOrderIngest(context.Background(), amqpURL, s)
|
||||
}
|
||||
|
||||
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
|
||||
@@ -292,7 +297,7 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
po := buildPlaceOrder(id, &req)
|
||||
po := s.buildPlaceOrder(id, &req)
|
||||
|
||||
st := flow.NewState(uint64(id), s.logger)
|
||||
st.Vars[order.PlaceOrderVar] = po
|
||||
@@ -320,7 +325,8 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// buildPlaceOrder converts a checkout request into the PlaceOrder event,
|
||||
// computing per-line and order totals (inc-vat) from the lines.
|
||||
func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
// Tax amounts are computed using the configured TaxProvider.
|
||||
func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
po := &messages.PlaceOrder{
|
||||
OrderReference: req.OrderReference,
|
||||
CartId: req.CartId,
|
||||
@@ -336,11 +342,7 @@ func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
var total, totalTax int64
|
||||
for _, l := range req.Lines {
|
||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
||||
var lineTax int64
|
||||
if l.TaxRate > 0 {
|
||||
// inc-vat -> tax portion = total * rate / (100 + rate)
|
||||
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
|
||||
}
|
||||
lineTax := s.taxProvider.ComputeTax(lineTotal, int(l.TaxRate))
|
||||
total += lineTotal
|
||||
totalTax += lineTax
|
||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||
@@ -450,10 +452,27 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
// selectTaxProvider picks the tax provider from the environment. Default is
|
||||
// NordicTaxProvider with SE as the default country (matching the current
|
||||
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
|
||||
// (no country awareness).
|
||||
func selectTaxProvider() order.TaxProvider {
|
||||
provider := os.Getenv("TAX_PROVIDER")
|
||||
switch provider {
|
||||
case "static":
|
||||
return order.NewStaticTaxProvider()
|
||||
case "nordic":
|
||||
return order.NewNordicTaxProvider(envOr("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
default:
|
||||
return order.NewNordicTaxProvider(envOr("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
}
|
||||
}
|
||||
|
||||
// selectProvider picks the payment provider from the environment. Default is
|
||||
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
|
||||
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
|
||||
func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
||||
|
||||
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
|
||||
key := os.Getenv("STRIPE_SECRET_KEY")
|
||||
if key == "" {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+220
-13
@@ -3,8 +3,11 @@ package customerauth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,9 +17,24 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// minPasswordLen is the minimum accepted password length at registration.
|
||||
// minPasswordLen is the minimum accepted password length at registration and
|
||||
// password reset.
|
||||
const minPasswordLen = 8
|
||||
|
||||
// Token lifetimes for the email-verification and password-reset links.
|
||||
const (
|
||||
verifyTokenTTL = 24 * time.Hour
|
||||
resetTokenTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Storefront paths (appended to Options.BaseURL) that the verification and reset
|
||||
// links point at. The page reads the token from the query string and POSTs it
|
||||
// back to /verify or /reset.
|
||||
const (
|
||||
verifyLinkPath = "/account/verify"
|
||||
resetLinkPath = "/account/reset"
|
||||
)
|
||||
|
||||
// ProfileApplier is the subset of the profile grain pool the auth server needs.
|
||||
// The pool (and the UCP adapter's ProfileApplier) already satisfies it.
|
||||
type ProfileApplier interface {
|
||||
@@ -24,21 +42,60 @@ type ProfileApplier interface {
|
||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
||||
}
|
||||
|
||||
// Server exposes password signup/login and identity-linking over HTTP, backed
|
||||
// by the credential store (email→id+hash) and the profile grain pool.
|
||||
// Server exposes password signup/login, email verification, password reset and
|
||||
// identity-linking over HTTP, backed by the credential store (email→id+hash) and
|
||||
// the profile grain pool.
|
||||
type Server struct {
|
||||
store *CredentialStore
|
||||
applier ProfileApplier
|
||||
signer *Signer
|
||||
ttl time.Duration
|
||||
store Credentials
|
||||
applier ProfileApplier
|
||||
signer *Signer
|
||||
ttl time.Duration
|
||||
limiter Limiter
|
||||
notifier Notifier
|
||||
baseURL string
|
||||
requireVerified bool
|
||||
}
|
||||
|
||||
// Options configures optional auth-server behavior. The zero value is valid: an
|
||||
// in-memory login limiter (5 failures / 15m), a logging notifier, and no
|
||||
// verified-email requirement for login.
|
||||
type Options struct {
|
||||
// Limiter throttles failed logins and reset requests. Nil installs a default
|
||||
// in-memory limiter (5 failures / 15m). Inject a RedisLoginLimiter for a
|
||||
// horizontally-scaled deployment.
|
||||
Limiter Limiter
|
||||
// Notifier delivers verification and reset links. Nil installs LogNotifier.
|
||||
Notifier Notifier
|
||||
// BaseURL is the public origin used to build links in messages, e.g.
|
||||
// "https://shop.tornberg.me". The verify/reset paths are appended to it.
|
||||
BaseURL string
|
||||
// RequireVerifiedEmail, when true, blocks login until the email is verified.
|
||||
// Off by default so pre-existing accounts (which carry no verification
|
||||
// record) keep working.
|
||||
RequireVerifiedEmail bool
|
||||
}
|
||||
|
||||
// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL.
|
||||
func NewServer(store *CredentialStore, applier ProfileApplier, signer *Signer, ttl time.Duration) *Server {
|
||||
func NewServer(store Credentials, applier ProfileApplier, signer *Signer, ttl time.Duration, opts Options) *Server {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultSessionTTL
|
||||
}
|
||||
return &Server{store: store, applier: applier, signer: signer, ttl: ttl}
|
||||
if opts.Limiter == nil {
|
||||
opts.Limiter = NewLoginLimiter(0, 0)
|
||||
}
|
||||
if opts.Notifier == nil {
|
||||
opts.Notifier = LogNotifier{}
|
||||
}
|
||||
return &Server{
|
||||
store: store,
|
||||
applier: applier,
|
||||
signer: signer,
|
||||
ttl: ttl,
|
||||
limiter: opts.Limiter,
|
||||
notifier: opts.Notifier,
|
||||
baseURL: strings.TrimRight(opts.BaseURL, "/"),
|
||||
requireVerified: opts.RequireVerifiedEmail,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth
|
||||
@@ -49,6 +106,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /me", s.handleMe)
|
||||
mux.HandleFunc("POST /verify-request", s.handleVerifyRequest)
|
||||
mux.HandleFunc("POST /verify", s.handleVerify)
|
||||
mux.HandleFunc("POST /reset-request", s.handleResetRequest)
|
||||
mux.HandleFunc("POST /reset", s.handleResetComplete)
|
||||
mux.HandleFunc("POST /link-cart", s.handleLinkCart)
|
||||
mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout)
|
||||
mux.HandleFunc("POST /link-order", s.handleLinkOrder)
|
||||
@@ -86,6 +147,22 @@ type linkOrderRequest struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// tokenRequest carries a verification token.
|
||||
type tokenRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// resetRequest asks for a password-reset link to be sent to an email.
|
||||
type resetRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// resetCompleteRequest carries a reset token and the new password.
|
||||
type resetCompleteRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
|
||||
// import cycle with internal/ucp). The password hash is never included.
|
||||
type CustomerResponse struct {
|
||||
@@ -98,6 +175,9 @@ type CustomerResponse struct {
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Addresses []AddressResponse `json:"addresses"`
|
||||
Orders []OrderRef `json:"orders"`
|
||||
|
||||
// EmailVerified reflects whether the email-verification flow has completed.
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
}
|
||||
|
||||
type AddressResponse struct {
|
||||
@@ -137,7 +217,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
if _, exists := s.store.Get(email); exists {
|
||||
if _, exists := s.store.Get(r.Context(), email); exists {
|
||||
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
||||
return
|
||||
}
|
||||
@@ -169,7 +249,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Persist the credential only after the profile is created. If this fails
|
||||
// the profile exists but is unreachable by login — acceptable and rare.
|
||||
if err := s.store.Register(email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
if err := s.store.Register(r.Context(), email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
if err == ErrEmailExists {
|
||||
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
||||
return
|
||||
@@ -178,6 +258,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send the verification link (best-effort, via the configured notifier).
|
||||
s.sendVerification(email)
|
||||
|
||||
s.issueSession(w, r, id)
|
||||
s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id)
|
||||
}
|
||||
@@ -188,14 +271,26 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
rec, ok := s.store.Get(req.Email)
|
||||
key := NormalizeEmail(req.Email)
|
||||
if ok, retry := s.limiter.Allowed(r.Context(), key); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
|
||||
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
||||
return
|
||||
}
|
||||
rec, ok := s.store.Get(r.Context(), req.Email)
|
||||
// Always run a verify (even on miss, against the stored hash if present) and
|
||||
// return a single generic error so the response does not reveal whether the
|
||||
// email exists.
|
||||
if !ok || !VerifyPassword(req.Password, rec.Hash) {
|
||||
s.limiter.RecordFailure(r.Context(), key)
|
||||
writeErr(w, http.StatusUnauthorized, "invalid email or password")
|
||||
return
|
||||
}
|
||||
if s.requireVerified && rec.VerifiedAt == "" {
|
||||
writeErr(w, http.StatusForbidden, "email not verified")
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(r.Context(), key)
|
||||
s.issueSession(w, r, rec.ProfileID)
|
||||
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID)
|
||||
}
|
||||
@@ -213,6 +308,101 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
||||
}
|
||||
|
||||
// handleVerifyRequest re-sends the email-verification link for the logged-in
|
||||
// customer. Requires a session so it can't be used to enumerate addresses.
|
||||
func (s *Server) handleVerifyRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.requireSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
if err != nil || g.Email == "" {
|
||||
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
||||
return
|
||||
}
|
||||
s.sendVerification(NormalizeEmail(g.Email))
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
|
||||
}
|
||||
|
||||
// handleVerify consumes a verification token and marks the email verified. An
|
||||
// unknown subject still returns success so the endpoint reveals nothing.
|
||||
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
var req tokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
email, err := s.signer.ParsePurpose(purposeVerifyEmail, req.Token)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.MarkVerified(r.Context(), email, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "could not record verification")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
|
||||
}
|
||||
|
||||
// handleResetRequest issues a password-reset link for a registered email. It
|
||||
// always returns 200 (whether or not the email exists) so it never reveals
|
||||
// account existence, and is rate-limited per email to prevent mail-bombing.
|
||||
func (s *Server) handleResetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
var req resetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
email := NormalizeEmail(req.Email)
|
||||
limiterKey := "reset:" + email
|
||||
if ok, retry := s.limiter.Allowed(r.Context(), limiterKey); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
|
||||
writeErr(w, http.StatusTooManyRequests, "too many requests, try again later")
|
||||
return
|
||||
}
|
||||
if _, exists := s.store.Get(r.Context(), email); exists {
|
||||
token := s.signer.IssuePurpose(purposePasswordReset, email, resetTokenTTL)
|
||||
s.notifier.SendPasswordReset(email, s.link(resetLinkPath, token))
|
||||
}
|
||||
s.limiter.RecordFailure(r.Context(), limiterKey)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleResetComplete consumes a reset token and replaces the password hash.
|
||||
func (s *Server) handleResetComplete(w http.ResponseWriter, r *http.Request) {
|
||||
var req resetCompleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < minPasswordLen {
|
||||
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
email, err := s.signer.ParsePurpose(purposePasswordReset, req.Token)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
hash, err := HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "could not hash password")
|
||||
return
|
||||
}
|
||||
found, err := s.store.UpdateHash(r.Context(), email, hash)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "could not update password")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
// Token signed for an email no longer present.
|
||||
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(r.Context(), NormalizeEmail(email))
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.requireSession(w, r)
|
||||
if !ok {
|
||||
@@ -292,6 +482,19 @@ func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, id uint64)
|
||||
SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl)
|
||||
}
|
||||
|
||||
// sendVerification mints a verification token for email and hands the link to
|
||||
// the notifier. email must already be normalized.
|
||||
func (s *Server) sendVerification(email string) {
|
||||
token := s.signer.IssuePurpose(purposeVerifyEmail, email, verifyTokenTTL)
|
||||
s.notifier.SendEmailVerification(email, s.link(verifyLinkPath, token))
|
||||
}
|
||||
|
||||
// link builds an absolute link to a storefront path carrying the token as a
|
||||
// query parameter. With no configured BaseURL it returns a relative link.
|
||||
func (s *Server) link(path, token string) string {
|
||||
return fmt.Sprintf("%s%s?token=%s", s.baseURL, path, url.QueryEscape(token))
|
||||
}
|
||||
|
||||
// requireSession reads and validates the session cookie, writing 401 on
|
||||
// failure. It returns the profile id and whether a valid session was present.
|
||||
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
||||
@@ -315,7 +518,11 @@ func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status in
|
||||
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
||||
return
|
||||
}
|
||||
writeJSON(w, status, grainToCustomer(idStr, g))
|
||||
resp := grainToCustomer(idStr, g)
|
||||
if rec, ok := s.store.Get(r.Context(), g.Email); ok {
|
||||
resp.EmailVerified = rec.VerifiedAt != ""
|
||||
}
|
||||
writeJSON(w, status, resp)
|
||||
}
|
||||
|
||||
func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,14 +15,36 @@ import (
|
||||
// associated with a credential record.
|
||||
var ErrEmailExists = errors.New("customerauth: email already registered")
|
||||
|
||||
// Credentials is the email→credential index the auth server depends on. It is
|
||||
// satisfied by the file-backed CredentialStore (single-instance / dev) and by
|
||||
// RedisCredentialStore (shared, horizontally scalable). All methods normalize
|
||||
// the email key internally.
|
||||
type Credentials interface {
|
||||
// Get returns the record for email and whether it exists. A backing-store
|
||||
// failure is reported as "not found" so callers fail closed.
|
||||
Get(ctx context.Context, email string) (Record, bool)
|
||||
// Register adds a new credential, returning ErrEmailExists if taken.
|
||||
Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error
|
||||
// MarkVerified records email verification. The bool reports whether the
|
||||
// email was known; an unknown email is not an error (avoids enumeration).
|
||||
MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error)
|
||||
// UpdateHash replaces the password hash. The bool reports whether the email
|
||||
// was known; an unknown email is not an error.
|
||||
UpdateHash(ctx context.Context, email, hash string) (bool, error)
|
||||
// Delete removes a credential record by email. The bool reports whether the email
|
||||
// was known.
|
||||
Delete(ctx context.Context, email string) (bool, error)
|
||||
}
|
||||
|
||||
// Record is a single stored credential: which profile an email belongs to and
|
||||
// its password hash. It deliberately does not embed any profile data — the
|
||||
// profile grain remains the source of truth for that.
|
||||
type Record struct {
|
||||
Email string `json:"email"`
|
||||
ProfileID uint64 `json:"profileId"`
|
||||
Hash string `json:"hash"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
Email string `json:"email"`
|
||||
ProfileID uint64 `json:"profileId"`
|
||||
Hash string `json:"hash"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
VerifiedAt string `json:"verifiedAt,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialStore is a small email→credential index persisted to a JSON file.
|
||||
@@ -61,8 +84,9 @@ func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// Get returns the record for email and whether it exists.
|
||||
func (s *CredentialStore) Get(email string) (Record, bool) {
|
||||
// Get returns the record for email and whether it exists. The ctx is accepted
|
||||
// for interface parity with the Redis store; the file store ignores it.
|
||||
func (s *CredentialStore) Get(_ context.Context, email string) (Record, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
r, ok := s.byMail[NormalizeEmail(email)]
|
||||
@@ -71,7 +95,7 @@ func (s *CredentialStore) Get(email string) (Record, bool) {
|
||||
|
||||
// Register adds a new credential record and persists the store. It returns
|
||||
// ErrEmailExists if the email is already taken.
|
||||
func (s *CredentialStore) Register(email string, profileID uint64, hash, createdAt string) error {
|
||||
func (s *CredentialStore) Register(_ context.Context, email string, profileID uint64, hash, createdAt string) error {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -82,6 +106,54 @@ func (s *CredentialStore) Register(email string, profileID uint64, hash, created
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
// MarkVerified records that email completed email verification and persists the
|
||||
// store. It is a no-op if already verified. The bool reports whether the email
|
||||
// was known; an unknown email is not an error (callers avoid leaking which
|
||||
// emails exist).
|
||||
func (s *CredentialStore) MarkVerified(_ context.Context, email, verifiedAt string) (bool, error) {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.byMail[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if rec.VerifiedAt != "" {
|
||||
return true, nil
|
||||
}
|
||||
rec.VerifiedAt = verifiedAt
|
||||
s.byMail[key] = rec
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// UpdateHash replaces the stored password hash for email and persists the store.
|
||||
// The bool reports whether the email was known; an unknown email is not an error.
|
||||
func (s *CredentialStore) UpdateHash(_ context.Context, email, hash string) (bool, error) {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.byMail[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
rec.Hash = hash
|
||||
s.byMail[key] = rec
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// Delete removes the credential record for email and persists the store.
|
||||
// The bool reports whether the email was known.
|
||||
func (s *CredentialStore) Delete(_ context.Context, email string) (bool, error) {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.byMail[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.byMail, key)
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// persistLocked writes the whole store to disk atomically. Caller holds s.mu.
|
||||
func (s *CredentialStore) persistLocked() error {
|
||||
records := make([]Record, 0, len(s.byMail))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
@@ -15,12 +16,18 @@ import (
|
||||
|
||||
// CustomerServer is the UCP REST adapter over the profile grain pool.
|
||||
type CustomerServer struct {
|
||||
applier ProfileApplier
|
||||
applier ProfileApplier
|
||||
deleter CredentialDeleter
|
||||
auditLogPath string
|
||||
}
|
||||
|
||||
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
|
||||
func NewCustomerServer(applier ProfileApplier) *CustomerServer {
|
||||
return &CustomerServer{applier: applier}
|
||||
func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) *CustomerServer {
|
||||
var del CredentialDeleter
|
||||
if len(deleter) > 0 {
|
||||
del = deleter[0]
|
||||
}
|
||||
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -276,6 +283,59 @@ func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||
}
|
||||
|
||||
// handleDeleteCustomer handles DELETE /customers/{id}.
|
||||
func (s *CustomerServer) handleDeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseCustomerID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Fetch current profile grain to get the email address.
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "customer not found")
|
||||
return
|
||||
}
|
||||
|
||||
email := g.Email
|
||||
|
||||
// 2. Apply the AnonymizeProfile mutation to clear all PII.
|
||||
msg := &messages.AnonymizeProfile{}
|
||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to anonymize customer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Delete the credentials from the auth store if we have a deleter.
|
||||
if email != "" && s.deleter != nil {
|
||||
if _, err := s.deleter.Delete(r.Context(), email); err != nil {
|
||||
fmt.Printf("ucp: failed to delete credentials for email %s: %v\n", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Log audit trail to file (GDPR requirement, append-only, readable, no PII)
|
||||
if s.auditLogPath != "" {
|
||||
logLine := fmt.Sprintf("[%s] ACTION=GDPR_ERASURE PROFILE_ID=%s STATUS=SUCCESS\n",
|
||||
time.Now().UTC().Format(time.RFC3339), r.PathValue("id"))
|
||||
|
||||
// Import "os" package is handled or we can open it directly.
|
||||
// Since we need to write to the file, let's open it in append-only mode.
|
||||
// To ensure directory exists, we write to the file.
|
||||
if f, err := os.OpenFile(s.auditLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
_, _ = f.WriteString(logLine)
|
||||
_ = f.Close()
|
||||
} else {
|
||||
fmt.Printf("ucp: failed to open audit log file %s: %v\n", s.auditLogPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log audit trail to stdout (GDPR requirement)
|
||||
fmt.Printf("AUDIT: GDPR right to erasure executed for customer profile id %d\n", id)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity linking helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -86,12 +86,13 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
|
||||
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
||||
//
|
||||
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
|
||||
func CustomerHandler(applier ProfileApplier) http.Handler {
|
||||
s := NewCustomerServer(applier)
|
||||
func CustomerHandler(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) http.Handler {
|
||||
s := NewCustomerServer(applier, auditLogPath, deleter...)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /", s.handleCreateCustomer)
|
||||
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
||||
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
||||
mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer)
|
||||
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
||||
mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress)
|
||||
mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress)
|
||||
@@ -104,9 +105,11 @@ func CustomerHandler(applier ProfileApplier) http.Handler {
|
||||
|
||||
// Options controls optional features of the combined handler.
|
||||
type Options struct {
|
||||
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
|
||||
OrderApplier OrderApplier // optional order creation for complete endpoint
|
||||
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
|
||||
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
|
||||
OrderApplier OrderApplier // optional order creation for complete endpoint
|
||||
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
|
||||
CredentialDeleter CredentialDeleter // optional, for deleting login credentials during GDPR erasure
|
||||
ProfileAuditLog string // file path for GDPR right-to-erasure audit log
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that serves all mounted UCP endpoints under
|
||||
@@ -148,10 +151,11 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
||||
|
||||
// Customer endpoints (optional)
|
||||
if opts.ProfileApplier != nil {
|
||||
customerSrv := NewCustomerServer(opts.ProfileApplier)
|
||||
customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
|
||||
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
|
||||
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
||||
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
||||
mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer)
|
||||
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
||||
mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress)
|
||||
mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
+13
-6
@@ -54,6 +54,8 @@ type CartItem struct {
|
||||
OrderReference string `json:"orderReference,omitempty"`
|
||||
IsSubscribed bool `json:"isSubscribed,omitempty"`
|
||||
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
|
||||
InventoryTracked bool `json:"inventoryTracked,omitempty"`
|
||||
DropShip bool `json:"dropShip,omitempty"`
|
||||
|
||||
// Extra holds arbitrary dynamic product data. Its keys are flattened onto
|
||||
// the item object in JSON (see cart_item_json.go), so they are returned
|
||||
@@ -152,12 +154,13 @@ type CartGrain struct {
|
||||
}
|
||||
|
||||
type Voucher struct {
|
||||
Code string `json:"code"`
|
||||
Applied bool `json:"applied"`
|
||||
Rules []string `json:"rules"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Id uint32 `json:"id"`
|
||||
Value int64 `json:"value"`
|
||||
Code string `json:"code"`
|
||||
Applied bool `json:"applied"`
|
||||
Rules []string `json:"rules"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Id uint32 `json:"id"`
|
||||
Value int64 `json:"value"`
|
||||
BypassedByPromotions bool `json:"-"`
|
||||
}
|
||||
|
||||
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||
@@ -298,6 +301,10 @@ func (c *CartGrain) UpdateTotals() {
|
||||
_, ok := voucher.AppliesTo(c)
|
||||
voucher.Applied = false
|
||||
if ok {
|
||||
if voucher.BypassedByPromotions {
|
||||
voucher.Applied = true
|
||||
continue
|
||||
}
|
||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||
if c.TotalPrice.IncVat <= value.IncVat {
|
||||
// don't apply discounts to more than the total price
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ func NewProfileMutationRegistry() actor.MutationRegistry {
|
||||
actor.NewMutation(HandleLinkCart),
|
||||
actor.NewMutation(HandleLinkCheckout),
|
||||
actor.NewMutation(HandleLinkOrder),
|
||||
actor.NewMutation(HandleAnonymizeProfile),
|
||||
)
|
||||
return reg
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package telemetry holds shared OpenTelemetry setup helpers used by the
|
||||
// service main packages (cart, checkout, profile, …).
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
)
|
||||
|
||||
// NewLoggerProvider builds the OTLP log provider from the environment, or returns
|
||||
// (nil, nil) when logs are disabled — in which case the caller MUST skip
|
||||
// global.SetLoggerProvider and the shutdown registration.
|
||||
//
|
||||
// Env:
|
||||
//
|
||||
// OTEL_LOGS_ENABLED "1"/"true"/"yes"/"on" to enable the OTLP log pipeline.
|
||||
// Default OFF: the batch processor clones its record ring
|
||||
// on every export, which profiling showed to be the
|
||||
// dominant heap allocator in these services. Traces and
|
||||
// metrics are always on regardless of this flag.
|
||||
// OTEL_LOGS_SAMPLE_RATIO fraction of records to export, 0.0–1.0 (default 1.0).
|
||||
// e.g. 0.1 keeps ~10% of logs. Dropped records never reach
|
||||
// the batch ring, so allocation churn falls ~proportionally.
|
||||
func NewLoggerProvider(ctx context.Context) (*log.LoggerProvider, error) {
|
||||
if !logsEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
exporter, err := otlploggrpc.New(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var proc log.Processor = log.NewBatchProcessor(exporter)
|
||||
if ratio := sampleRatio(); ratio < 1.0 {
|
||||
proc = &samplingProcessor{next: proc, ratio: ratio}
|
||||
}
|
||||
return log.NewLoggerProvider(log.WithProcessor(proc)), nil
|
||||
}
|
||||
|
||||
func logsEnabled() bool {
|
||||
switch os.Getenv("OTEL_LOGS_ENABLED") {
|
||||
case "1", "true", "TRUE", "True", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sampleRatio() float64 {
|
||||
v := os.Getenv("OTEL_LOGS_SAMPLE_RATIO")
|
||||
if v == "" {
|
||||
return 1.0
|
||||
}
|
||||
r, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil || r < 0 {
|
||||
return 1.0
|
||||
}
|
||||
if r > 1 {
|
||||
return 1.0
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// samplingProcessor forwards a random `ratio` fraction of records to the wrapped
|
||||
// processor and drops the rest before they reach the (allocation-heavy) batch
|
||||
// ring. This is head sampling: cheap, stateless, and per-record independent.
|
||||
type samplingProcessor struct {
|
||||
next log.Processor
|
||||
ratio float64
|
||||
}
|
||||
|
||||
func (s *samplingProcessor) Enabled(ctx context.Context, param log.EnabledParameters) bool {
|
||||
return s.next.Enabled(ctx, param)
|
||||
}
|
||||
|
||||
func (s *samplingProcessor) OnEmit(ctx context.Context, record *log.Record) error {
|
||||
if rand.Float64() >= s.ratio {
|
||||
return nil // dropped by sampling
|
||||
}
|
||||
return s.next.OnEmit(ctx, record)
|
||||
}
|
||||
|
||||
func (s *samplingProcessor) Shutdown(ctx context.Context) error { return s.next.Shutdown(ctx) }
|
||||
func (s *samplingProcessor) ForceFlush(ctx context.Context) error { return s.next.ForceFlush(ctx) }
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,22 +6,24 @@ import (
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
// SetupOTelSDK bootstraps the OpenTelemetry pipeline (propagator + traces +
|
||||
// metrics, and an opt-in logger — see NewLoggerProvider). It is shared by every
|
||||
// service main package; the service name comes from OTEL_RESOURCE_ATTRIBUTES, so
|
||||
// the same setup works everywhere. If it returns no error, call the returned
|
||||
// shutdown for proper cleanup.
|
||||
func SetupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
var shutdownFuncs []func(context.Context) error
|
||||
var err error
|
||||
|
||||
// shutdown calls the registered cleanup functions, joining their errors.
|
||||
shutdown := func(ctx context.Context) error {
|
||||
var err error
|
||||
for _, fn := range shutdownFuncs {
|
||||
@@ -31,12 +33,12 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// handleErr runs shutdown and surfaces all errors.
|
||||
handleErr := func(inErr error) {
|
||||
err = errors.Join(inErr, shutdown(ctx))
|
||||
}
|
||||
|
||||
prop := newPropagator()
|
||||
otel.SetTextMapPropagator(prop)
|
||||
otel.SetTextMapPropagator(newPropagator())
|
||||
|
||||
tracerProvider, err := newTracerProvider()
|
||||
if err != nil {
|
||||
@@ -54,13 +56,17 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
|
||||
loggerProvider, err := newLoggerProvider()
|
||||
// Logger provider is opt-in via OTEL_LOGS_ENABLED; nil means logs are off.
|
||||
// Traces + metrics above are always on.
|
||||
loggerProvider, err := NewLoggerProvider(ctx)
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||
global.SetLoggerProvider(loggerProvider)
|
||||
if loggerProvider != nil {
|
||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||
global.SetLoggerProvider(loggerProvider)
|
||||
}
|
||||
|
||||
return shutdown, err
|
||||
}
|
||||
@@ -77,12 +83,9 @@ func newTracerProvider() (*trace.TracerProvider, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracerProvider := trace.NewTracerProvider(
|
||||
trace.WithBatcher(traceExporter,
|
||||
trace.WithBatchTimeout(time.Second)),
|
||||
)
|
||||
return tracerProvider, nil
|
||||
return trace.NewTracerProvider(
|
||||
trace.WithBatcher(traceExporter, trace.WithBatchTimeout(time.Second)),
|
||||
), nil
|
||||
}
|
||||
|
||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
||||
@@ -90,19 +93,5 @@ func newMeterProvider() (*metric.MeterProvider, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
||||
logExporter, err := otlploggrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loggerProvider := log.NewLoggerProvider(
|
||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
||||
)
|
||||
return loggerProvider, nil
|
||||
return metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter))), nil
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
+151
-130
@@ -93,9 +93,11 @@ type AddItem struct {
|
||||
ExtraJson []byte `protobuf:"bytes,27,opt,name=extra_json,json=extraJson,proto3" json:"extra_json,omitempty"`
|
||||
// custom_fields holds optional user-supplied input fields for this line
|
||||
// (e.g. engraving text, configurator notes), keyed by field name.
|
||||
CustomFields map[string]string `protobuf:"bytes,28,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
CustomFields map[string]string `protobuf:"bytes,28,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
InventoryTracked bool `protobuf:"varint,29,opt,name=inventory_tracked,json=inventoryTracked,proto3" json:"inventory_tracked,omitempty"`
|
||||
DropShip bool `protobuf:"varint,30,opt,name=drop_ship,json=dropShip,proto3" json:"drop_ship,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AddItem) Reset() {
|
||||
@@ -324,6 +326,20 @@ func (x *AddItem) GetCustomFields() map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AddItem) GetInventoryTracked() bool {
|
||||
if x != nil {
|
||||
return x.InventoryTracked
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *AddItem) GetDropShip() bool {
|
||||
if x != nil {
|
||||
return x.DropShip
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type RemoveItem struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
|
||||
@@ -1113,7 +1129,7 @@ var file_cart_proto_rawDesc = string([]byte{
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||
0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72,
|
||||
0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xe0, 0x07, 0x0a, 0x07,
|
||||
0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xaa, 0x08, 0x0a, 0x07,
|
||||
0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74, 0x65, 0x6d, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64,
|
||||
0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01,
|
||||
@@ -1168,133 +1184,138 @@ var file_cart_proto_rawDesc = string([]byte{
|
||||
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74,
|
||||
0x65, 0x6d, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
||||
0x64, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
||||
0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
|
||||
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
|
||||
0x02, 0x38, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a,
|
||||
0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70,
|
||||
0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65,
|
||||
0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c,
|
||||
0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02,
|
||||
0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e,
|
||||
0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e,
|
||||
0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05,
|
||||
0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65,
|
||||
0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22,
|
||||
0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69,
|
||||
0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02,
|
||||
0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d,
|
||||
0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e,
|
||||
0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67,
|
||||
0x22, 0x27, 0x0a, 0x15, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
||||
0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x17, 0x53, 0x65,
|
||||
0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46,
|
||||
0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x5d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f,
|
||||
0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x63,
|
||||
0x64, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x69, 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x5f,
|
||||
0x74, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69,
|
||||
0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x12,
|
||||
0x1b, 0x0a, 0x09, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x73, 0x68, 0x69, 0x70, 0x18, 0x1e, 0x20, 0x01,
|
||||
0x28, 0x08, 0x52, 0x08, 0x64, 0x72, 0x6f, 0x70, 0x53, 0x68, 0x69, 0x70, 0x1a, 0x3f, 0x0a, 0x11,
|
||||
0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||
0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x09, 0x0a,
|
||||
0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f,
|
||||
0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49,
|
||||
0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f,
|
||||
0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f,
|
||||
0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
|
||||
0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
||||
0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
||||
0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
|
||||
0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e,
|
||||
0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04,
|
||||
0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65,
|
||||
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x22, 0x27, 0x0a, 0x15, 0x52, 0x65,
|
||||
0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b,
|
||||
0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52,
|
||||
0x02, 0x69, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49,
|
||||
0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12,
|
||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12,
|
||||
0x5d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73,
|
||||
0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
||||
0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x2e, 0x43,
|
||||
0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
|
||||
0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x3f,
|
||||
0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22,
|
||||
0x71, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41,
|
||||
0x64, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09,
|
||||
0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72,
|
||||
0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
|
||||
0x63, 0x65, 0x22, 0x7c, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72,
|
||||
0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||
0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x76, 0x6f,
|
||||
0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09,
|
||||
0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x20,
|
||||
0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
||||
0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69,
|
||||
0x64, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73,
|
||||
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12,
|
||||
0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x02, 0x69,
|
||||
0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e, 0x67,
|
||||
0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x65,
|
||||
0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e,
|
||||
0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73,
|
||||
0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x04, 0x64, 0x61,
|
||||
0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04,
|
||||
0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x22, 0xa8, 0x07, 0x0a, 0x08,
|
||||
0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x40, 0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61,
|
||||
0x72, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63,
|
||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x65,
|
||||
0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52,
|
||||
0x09, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x12, 0x33, 0x0a, 0x08, 0x61, 0x64,
|
||||
0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63,
|
||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64,
|
||||
0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x07, 0x61, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12,
|
||||
0x3c, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x48,
|
||||
0x00, 0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x48, 0x0a,
|
||||
0x0f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79,
|
||||
0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61,
|
||||
0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x00, 0x52, 0x0e, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51,
|
||||
0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3a, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x75,
|
||||
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63,
|
||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
|
||||
0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69,
|
||||
0x65, 0x6c, 0x64, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64,
|
||||
0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69,
|
||||
0x65, 0x6c, 0x64, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69,
|
||||
0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x71, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69,
|
||||
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x74,
|
||||
0x65, 0x6d, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d,
|
||||
0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64,
|
||||
0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
|
||||
0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52,
|
||||
0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x22, 0x7c, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x56,
|
||||
0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,
|
||||
0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
|
||||
0x12, 0x22, 0x0a, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73,
|
||||
0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52,
|
||||
0x75, 0x6c, 0x65, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72,
|
||||
0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
|
||||
0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65,
|
||||
0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65,
|
||||
0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x48, 0x00, 0x52, 0x02, 0x69, 0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66,
|
||||
0x66, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20,
|
||||
0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65,
|
||||
0x12, 0x28, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
|
||||
0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
|
||||
0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69,
|
||||
0x64, 0x22, 0xa8, 0x07, 0x0a, 0x08, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x40,
|
||||
0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x73, 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74,
|
||||
0x12, 0x33, 0x0a, 0x08, 0x61, 0x64, 0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x07, 0x61, 0x64,
|
||||
0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x3c, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f,
|
||||
0x69, 0x74, 0x65, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72,
|
||||
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76,
|
||||
0x65, 0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49,
|
||||
0x74, 0x65, 0x6d, 0x12, 0x48, 0x0a, 0x0f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x71, 0x75,
|
||||
0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x63,
|
||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x68, 0x61,
|
||||
0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x00, 0x52, 0x0e, 0x63,
|
||||
0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3a, 0x0a,
|
||||
0x0b, 0x73, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, 0x00, 0x52, 0x09,
|
||||
0x73, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x4c, 0x0a, 0x11, 0x6c, 0x69, 0x6e,
|
||||
0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x06,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72,
|
||||
0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x0f, 0x6c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d,
|
||||
0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x5f, 0x0a, 0x18, 0x72, 0x65, 0x6d, 0x6f, 0x76,
|
||||
0x65, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b,
|
||||
0x69, 0x6e, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x63, 0x61, 0x72, 0x74,
|
||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
|
||||
0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48,
|
||||
0x00, 0x52, 0x15, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65,
|
||||
0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x51, 0x0a, 0x12, 0x73, 0x75, 0x62, 0x73,
|
||||
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x08,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
|
||||
0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x48, 0x00, 0x52, 0x11, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72,
|
||||
0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x66, 0x0a, 0x1b, 0x73,
|
||||
0x65, 0x74, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x75, 0x73,
|
||||
0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x26, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
||||
0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74,
|
||||
0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x48, 0x00, 0x52, 0x17, 0x73, 0x65, 0x74, 0x4c,
|
||||
0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65,
|
||||
0x6c, 0x64, 0x73, 0x12, 0x3c, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68,
|
||||
0x65, 0x72, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f,
|
||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63,
|
||||
0x68, 0x65, 0x72, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
||||
0x72, 0x12, 0x45, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x76, 0x6f, 0x75, 0x63,
|
||||
0x68, 0x65, 0x72, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x61, 0x72, 0x74,
|
||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
|
||||
0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76,
|
||||
0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x6a, 0x0a, 0x1b, 0x75, 0x70, 0x73, 0x65,
|
||||
0x72, 0x74, 0x5f, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
|
||||
0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e,
|
||||
0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70,
|
||||
0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x48, 0x00, 0x52, 0x19, 0x75, 0x70, 0x73, 0x65, 0x72,
|
||||
0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74,
|
||||
0x61, 0x69, 0x6c, 0x73, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x39, 0x5a, 0x37,
|
||||
0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73,
|
||||
0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x61, 0x72, 0x74, 0x3b, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x74, 0x55, 0x73, 0x65,
|
||||
0x72, 0x49, 0x64, 0x12, 0x4c, 0x0a, 0x11, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d,
|
||||
0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
|
||||
0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c,
|
||||
0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00,
|
||||
0x52, 0x0f, 0x6c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e,
|
||||
0x67, 0x12, 0x5f, 0x0a, 0x18, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x6c, 0x69, 0x6e, 0x65,
|
||||
0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x07, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||
0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
||||
0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x15, 0x72, 0x65, 0x6d,
|
||||
0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69,
|
||||
0x6e, 0x67, 0x12, 0x51, 0x0a, 0x12, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20,
|
||||
0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53,
|
||||
0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64,
|
||||
0x48, 0x00, 0x52, 0x11, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x66, 0x0a, 0x1b, 0x73, 0x65, 0x74, 0x5f, 0x6c, 0x69, 0x6e,
|
||||
0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69,
|
||||
0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x63, 0x61, 0x72,
|
||||
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69,
|
||||
0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
||||
0x64, 0x73, 0x48, 0x00, 0x52, 0x17, 0x73, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65,
|
||||
0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x3c, 0x0a,
|
||||
0x0b, 0x61, 0x64, 0x64, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x14, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52,
|
||||
0x0a, 0x61, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x45, 0x0a, 0x0e, 0x72,
|
||||
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x15, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||
0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
||||
0x72, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68,
|
||||
0x65, 0x72, 0x12, 0x6a, 0x0a, 0x1b, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x5f, 0x73, 0x75, 0x62,
|
||||
0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c,
|
||||
0x73, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75,
|
||||
0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c,
|
||||
0x73, 0x48, 0x00, 0x52, 0x19, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63,
|
||||
0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x42, 0x06,
|
||||
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36,
|
||||
0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61,
|
||||
0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63,
|
||||
0x61, 0x72, 0x74, 0x3b, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||
0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
})
|
||||
|
||||
var (
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+262
-200
@@ -2,7 +2,7 @@
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.5
|
||||
// protoc v7.35.0
|
||||
// source: proto/profile.proto
|
||||
// source: profile.proto
|
||||
|
||||
package profile_messages
|
||||
|
||||
@@ -42,7 +42,7 @@ type Address struct {
|
||||
|
||||
func (x *Address) Reset() {
|
||||
*x = Address{}
|
||||
mi := &file_proto_profile_proto_msgTypes[0]
|
||||
mi := &file_profile_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (x *Address) String() string {
|
||||
func (*Address) ProtoMessage() {}
|
||||
|
||||
func (x *Address) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_profile_proto_msgTypes[0]
|
||||
mi := &file_profile_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -67,7 +67,7 @@ func (x *Address) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Address.ProtoReflect.Descriptor instead.
|
||||
func (*Address) Descriptor() ([]byte, []int) {
|
||||
return file_proto_profile_proto_rawDescGZIP(), []int{0}
|
||||
return file_profile_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *Address) GetId() uint32 {
|
||||
@@ -169,7 +169,7 @@ type SetProfile struct {
|
||||
|
||||
func (x *SetProfile) Reset() {
|
||||
*x = SetProfile{}
|
||||
mi := &file_proto_profile_proto_msgTypes[1]
|
||||
mi := &file_profile_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func (x *SetProfile) String() string {
|
||||
func (*SetProfile) ProtoMessage() {}
|
||||
|
||||
func (x *SetProfile) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_profile_proto_msgTypes[1]
|
||||
mi := &file_profile_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -194,7 +194,7 @@ func (x *SetProfile) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use SetProfile.ProtoReflect.Descriptor instead.
|
||||
func (*SetProfile) Descriptor() ([]byte, []int) {
|
||||
return file_proto_profile_proto_rawDescGZIP(), []int{1}
|
||||
return file_profile_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *SetProfile) GetName() string {
|
||||
@@ -249,7 +249,7 @@ type AddAddress struct {
|
||||
|
||||
func (x *AddAddress) Reset() {
|
||||
*x = AddAddress{}
|
||||
mi := &file_proto_profile_proto_msgTypes[2]
|
||||
mi := &file_profile_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func (x *AddAddress) String() string {
|
||||
func (*AddAddress) ProtoMessage() {}
|
||||
|
||||
func (x *AddAddress) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_profile_proto_msgTypes[2]
|
||||
mi := &file_profile_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -274,7 +274,7 @@ func (x *AddAddress) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use AddAddress.ProtoReflect.Descriptor instead.
|
||||
func (*AddAddress) Descriptor() ([]byte, []int) {
|
||||
return file_proto_profile_proto_rawDescGZIP(), []int{2}
|
||||
return file_profile_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *AddAddress) GetAddress() *Address {
|
||||
@@ -305,7 +305,7 @@ type UpdateAddress struct {
|
||||
|
||||
func (x *UpdateAddress) Reset() {
|
||||
*x = UpdateAddress{}
|
||||
mi := &file_proto_profile_proto_msgTypes[3]
|
||||
mi := &file_profile_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -317,7 +317,7 @@ func (x *UpdateAddress) String() string {
|
||||
func (*UpdateAddress) ProtoMessage() {}
|
||||
|
||||
func (x *UpdateAddress) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_profile_proto_msgTypes[3]
|
||||
mi := &file_profile_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -330,7 +330,7 @@ func (x *UpdateAddress) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use UpdateAddress.ProtoReflect.Descriptor instead.
|
||||
func (*UpdateAddress) Descriptor() ([]byte, []int) {
|
||||
return file_proto_profile_proto_rawDescGZIP(), []int{3}
|
||||
return file_profile_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *UpdateAddress) GetId() uint32 {
|
||||
@@ -427,7 +427,7 @@ type RemoveAddress struct {
|
||||
|
||||
func (x *RemoveAddress) Reset() {
|
||||
*x = RemoveAddress{}
|
||||
mi := &file_proto_profile_proto_msgTypes[4]
|
||||
mi := &file_profile_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -439,7 +439,7 @@ func (x *RemoveAddress) String() string {
|
||||
func (*RemoveAddress) ProtoMessage() {}
|
||||
|
||||
func (x *RemoveAddress) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_profile_proto_msgTypes[4]
|
||||
mi := &file_profile_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -452,7 +452,7 @@ func (x *RemoveAddress) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RemoveAddress.ProtoReflect.Descriptor instead.
|
||||
func (*RemoveAddress) Descriptor() ([]byte, []int) {
|
||||
return file_proto_profile_proto_rawDescGZIP(), []int{4}
|
||||
return file_profile_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *RemoveAddress) GetId() uint32 {
|
||||
@@ -473,7 +473,7 @@ type LinkCart struct {
|
||||
|
||||
func (x *LinkCart) Reset() {
|
||||
*x = LinkCart{}
|
||||
mi := &file_proto_profile_proto_msgTypes[5]
|
||||
mi := &file_profile_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -485,7 +485,7 @@ func (x *LinkCart) String() string {
|
||||
func (*LinkCart) ProtoMessage() {}
|
||||
|
||||
func (x *LinkCart) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_profile_proto_msgTypes[5]
|
||||
mi := &file_profile_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -498,7 +498,7 @@ func (x *LinkCart) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use LinkCart.ProtoReflect.Descriptor instead.
|
||||
func (*LinkCart) Descriptor() ([]byte, []int) {
|
||||
return file_proto_profile_proto_rawDescGZIP(), []int{5}
|
||||
return file_profile_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *LinkCart) GetCartId() uint64 {
|
||||
@@ -526,7 +526,7 @@ type LinkCheckout struct {
|
||||
|
||||
func (x *LinkCheckout) Reset() {
|
||||
*x = LinkCheckout{}
|
||||
mi := &file_proto_profile_proto_msgTypes[6]
|
||||
mi := &file_profile_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -538,7 +538,7 @@ func (x *LinkCheckout) String() string {
|
||||
func (*LinkCheckout) ProtoMessage() {}
|
||||
|
||||
func (x *LinkCheckout) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_profile_proto_msgTypes[6]
|
||||
mi := &file_profile_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -551,7 +551,7 @@ func (x *LinkCheckout) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use LinkCheckout.ProtoReflect.Descriptor instead.
|
||||
func (*LinkCheckout) Descriptor() ([]byte, []int) {
|
||||
return file_proto_profile_proto_rawDescGZIP(), []int{6}
|
||||
return file_profile_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *LinkCheckout) GetCheckoutId() uint64 {
|
||||
@@ -580,7 +580,7 @@ type LinkOrder struct {
|
||||
|
||||
func (x *LinkOrder) Reset() {
|
||||
*x = LinkOrder{}
|
||||
mi := &file_proto_profile_proto_msgTypes[7]
|
||||
mi := &file_profile_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -592,7 +592,7 @@ func (x *LinkOrder) String() string {
|
||||
func (*LinkOrder) ProtoMessage() {}
|
||||
|
||||
func (x *LinkOrder) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_profile_proto_msgTypes[7]
|
||||
mi := &file_profile_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -605,7 +605,7 @@ func (x *LinkOrder) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use LinkOrder.ProtoReflect.Descriptor instead.
|
||||
func (*LinkOrder) Descriptor() ([]byte, []int) {
|
||||
return file_proto_profile_proto_rawDescGZIP(), []int{7}
|
||||
return file_profile_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *LinkOrder) GetOrderReference() string {
|
||||
@@ -629,6 +629,43 @@ func (x *LinkOrder) GetStatus() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// AnonymizeProfile wipes all PII from the customer profile.
|
||||
type AnonymizeProfile struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AnonymizeProfile) Reset() {
|
||||
*x = AnonymizeProfile{}
|
||||
mi := &file_profile_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *AnonymizeProfile) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*AnonymizeProfile) ProtoMessage() {}
|
||||
|
||||
func (x *AnonymizeProfile) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_profile_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use AnonymizeProfile.ProtoReflect.Descriptor instead.
|
||||
func (*AnonymizeProfile) Descriptor() ([]byte, []int) {
|
||||
return file_profile_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
type Mutation struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Type:
|
||||
@@ -640,6 +677,7 @@ type Mutation struct {
|
||||
// *Mutation_LinkCart
|
||||
// *Mutation_LinkCheckout
|
||||
// *Mutation_LinkOrder
|
||||
// *Mutation_AnonymizeProfile
|
||||
Type isMutation_Type `protobuf_oneof:"type"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -647,7 +685,7 @@ type Mutation struct {
|
||||
|
||||
func (x *Mutation) Reset() {
|
||||
*x = Mutation{}
|
||||
mi := &file_proto_profile_proto_msgTypes[8]
|
||||
mi := &file_profile_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -659,7 +697,7 @@ func (x *Mutation) String() string {
|
||||
func (*Mutation) ProtoMessage() {}
|
||||
|
||||
func (x *Mutation) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_profile_proto_msgTypes[8]
|
||||
mi := &file_profile_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -672,7 +710,7 @@ func (x *Mutation) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Mutation.ProtoReflect.Descriptor instead.
|
||||
func (*Mutation) Descriptor() ([]byte, []int) {
|
||||
return file_proto_profile_proto_rawDescGZIP(), []int{8}
|
||||
return file_profile_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *Mutation) GetType() isMutation_Type {
|
||||
@@ -745,6 +783,15 @@ func (x *Mutation) GetLinkOrder() *LinkOrder {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Mutation) GetAnonymizeProfile() *AnonymizeProfile {
|
||||
if x != nil {
|
||||
if x, ok := x.Type.(*Mutation_AnonymizeProfile); ok {
|
||||
return x.AnonymizeProfile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isMutation_Type interface {
|
||||
isMutation_Type()
|
||||
}
|
||||
@@ -777,6 +824,10 @@ type Mutation_LinkOrder struct {
|
||||
LinkOrder *LinkOrder `protobuf:"bytes,7,opt,name=link_order,json=linkOrder,proto3,oneof"`
|
||||
}
|
||||
|
||||
type Mutation_AnonymizeProfile struct {
|
||||
AnonymizeProfile *AnonymizeProfile `protobuf:"bytes,8,opt,name=anonymize_profile,json=anonymizeProfile,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Mutation_SetProfile) isMutation_Type() {}
|
||||
|
||||
func (*Mutation_AddAddress) isMutation_Type() {}
|
||||
@@ -791,170 +842,179 @@ func (*Mutation_LinkCheckout) isMutation_Type() {}
|
||||
|
||||
func (*Mutation_LinkOrder) isMutation_Type() {}
|
||||
|
||||
var File_proto_profile_proto protoreflect.FileDescriptor
|
||||
func (*Mutation_AnonymizeProfile) isMutation_Type() {}
|
||||
|
||||
var file_proto_profile_proto_rawDesc = string([]byte{
|
||||
0x0a, 0x13, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xfe, 0x02, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72,
|
||||
0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52,
|
||||
0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x75, 0x6c,
|
||||
0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x75, 0x6c,
|
||||
0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
||||
0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||
0x00, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88,
|
||||
0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18,
|
||||
0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03,
|
||||
0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x12, 0x18,
|
||||
0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e,
|
||||
0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65,
|
||||
0x88, 0x01, 0x01, 0x12, 0x2c, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
|
||||
0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11,
|
||||
0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e,
|
||||
0x67, 0x12, 0x2a, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69,
|
||||
0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44,
|
||||
0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x42, 0x0f, 0x0a,
|
||||
0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x08,
|
||||
0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x22, 0x85, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x74,
|
||||
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01,
|
||||
0x12, 0x19, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||
0x01, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70,
|
||||
0x68, 0x6f, 0x6e, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x05, 0x70, 0x68,
|
||||
0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61,
|
||||
0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67,
|
||||
0x75, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65,
|
||||
0x6e, 0x63, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x08, 0x63, 0x75, 0x72,
|
||||
0x72, 0x65, 0x6e, 0x63, 0x79, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x74,
|
||||
0x61, 0x72, 0x55, 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x09, 0x61,
|
||||
0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f,
|
||||
0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08,
|
||||
0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x6e,
|
||||
0x67, 0x75, 0x61, 0x67, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
|
||||
0x63, 0x79, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c,
|
||||
0x22, 0x41, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x33,
|
||||
0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x19, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72,
|
||||
0x65, 0x73, 0x73, 0x22, 0xab, 0x04, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64,
|
||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x88, 0x01, 0x01,
|
||||
0x12, 0x1f, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x48, 0x01, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01,
|
||||
0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65,
|
||||
0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65,
|
||||
0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64,
|
||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
|
||||
0x48, 0x03, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32,
|
||||
0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28,
|
||||
0x09, 0x48, 0x04, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05,
|
||||
0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x05, 0x73,
|
||||
0x74, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08,
|
||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d,
|
||||
0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||
0x07, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a,
|
||||
0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x08, 0x52, 0x05,
|
||||
0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65,
|
||||
0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20,
|
||||
0x01, 0x28, 0x08, 0x48, 0x09, 0x52, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
|
||||
0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x69,
|
||||
var File_profile_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_profile_proto_rawDesc = string([]byte{
|
||||
0x0a, 0x0d, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12,
|
||||
0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||
0x73, 0x22, 0xfe, 0x02, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a,
|
||||
0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a,
|
||||
0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61,
|
||||
0x62, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12,
|
||||
0x22, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18,
|
||||
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69,
|
||||
0x6e, 0x65, 0x31, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69,
|
||||
0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x61, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04,
|
||||
0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79,
|
||||
0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||
0x72, 0x79, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28,
|
||||
0x09, 0x48, 0x01, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x2c, 0x0a,
|
||||
0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69,
|
||||
0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61,
|
||||
0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x2a, 0x0a, 0x10, 0x69,
|
||||
0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18,
|
||||
0x0c, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0a, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75,
|
||||
0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06,
|
||||
0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x4e,
|
||||
0x61, 0x6d, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c,
|
||||
0x69, 0x6e, 0x65, 0x31, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
||||
0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x08,
|
||||
0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70,
|
||||
0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x08, 0x0a, 0x06,
|
||||
0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66,
|
||||
0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x42, 0x13, 0x0a, 0x11,
|
||||
0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e,
|
||||
0x67, 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65,
|
||||
0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02,
|
||||
0x69, 0x64, 0x22, 0x38, 0x0a, 0x08, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x16,
|
||||
0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06,
|
||||
0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x46, 0x0a, 0x0c,
|
||||
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x1e, 0x0a, 0x0a,
|
||||
0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04,
|
||||
0x52, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06,
|
||||
0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61,
|
||||
0x72, 0x74, 0x49, 0x64, 0x22, 0x63, 0x0a, 0x09, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65,
|
||||
0x72, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65,
|
||||
0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72,
|
||||
0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72,
|
||||
0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49,
|
||||
0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xe8, 0x03, 0x0a, 0x08, 0x4d, 0x75,
|
||||
0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3f, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x72,
|
||||
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53,
|
||||
0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x74,
|
||||
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x3f, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x61,
|
||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||
0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64,
|
||||
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x75, 0x70, 0x64, 0x61,
|
||||
0x74, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||
0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
|
||||
0x73, 0x48, 0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65,
|
||||
0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65,
|
||||
0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72,
|
||||
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09,
|
||||
0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c,
|
||||
0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x45, 0x0a, 0x0d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f,
|
||||
0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
|
||||
0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
|
||||
0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72,
|
||||
0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f,
|
||||
0x6e, 0x65, 0x22, 0x85, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c,
|
||||
0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||
0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x65, 0x6d,
|
||||
0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x65, 0x6d, 0x61,
|
||||
0x69, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01,
|
||||
0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01,
|
||||
0x28, 0x09, 0x48, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x88, 0x01,
|
||||
0x01, 0x12, 0x1f, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x05, 0x20,
|
||||
0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x88,
|
||||
0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x18,
|
||||
0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55,
|
||||
0x72, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08,
|
||||
0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f,
|
||||
0x6e, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x42,
|
||||
0x0b, 0x0a, 0x09, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x0c, 0x0a, 0x0a,
|
||||
0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x22, 0x41, 0x0a, 0x0a, 0x41, 0x64,
|
||||
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x33, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72,
|
||||
0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x66,
|
||||
0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0xab, 0x04,
|
||||
0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12,
|
||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12,
|
||||
0x19, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
|
||||
0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x66, 0x75,
|
||||
0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x08,
|
||||
0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61,
|
||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||
0x09, 0x48, 0x02, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65,
|
||||
0x31, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c,
|
||||
0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x0c, 0x61, 0x64,
|
||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a,
|
||||
0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x04, 0x63,
|
||||
0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18,
|
||||
0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x88, 0x01,
|
||||
0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06,
|
||||
0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48, 0x07, 0x52, 0x07, 0x63, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65,
|
||||
0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x08, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88,
|
||||
0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53,
|
||||
0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x48, 0x09, 0x52,
|
||||
0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69,
|
||||
0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75,
|
||||
0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48,
|
||||
0x0a, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c,
|
||||
0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c,
|
||||
0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x0f, 0x0a,
|
||||
0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x42, 0x0f,
|
||||
0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42,
|
||||
0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61,
|
||||
0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63,
|
||||
0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65,
|
||||
0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68,
|
||||
0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66,
|
||||
0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x22, 0x1f, 0x0a, 0x0d, 0x52,
|
||||
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0x38, 0x0a, 0x08,
|
||||
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74,
|
||||
0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64,
|
||||
0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x46, 0x0a, 0x0c, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68,
|
||||
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f,
|
||||
0x75, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x63, 0x68, 0x65, 0x63,
|
||||
0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x22, 0x63,
|
||||
0x0a, 0x09, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x6f,
|
||||
0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65,
|
||||
0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61,
|
||||
0x74, 0x75, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65,
|
||||
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x22, 0xbb, 0x04, 0x0a, 0x08, 0x4d, 0x75, 0x74, 0x61,
|
||||
0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3f, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x66,
|
||||
0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x66,
|
||||
0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
|
||||
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x74, 0x50, 0x72,
|
||||
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x3f, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x61, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64,
|
||||
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x64, 0x41,
|
||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65,
|
||||
0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||
0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x48, 0x00,
|
||||
0x52, 0x0c, 0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x3c,
|
||||
0x0a, 0x0a, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73,
|
||||
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x48,
|
||||
0x00, 0x52, 0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x06, 0x0a, 0x04,
|
||||
0x74, 0x79, 0x70, 0x65, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e,
|
||||
0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74,
|
||||
0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f,
|
||||
0x66, 0x69, 0x6c, 0x65, 0x3b, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73,
|
||||
0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48,
|
||||
0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
||||
0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65,
|
||||
0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69,
|
||||
0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f,
|
||||
0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d,
|
||||
0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09, 0x6c, 0x69,
|
||||
0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
||||
0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x69, 0x6e,
|
||||
0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x45, 0x0a, 0x0d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x68,
|
||||
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x48, 0x00, 0x52, 0x0c,
|
||||
0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x3c, 0x0a, 0x0a,
|
||||
0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||
0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x48, 0x00, 0x52,
|
||||
0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x51, 0x0a, 0x11, 0x61, 0x6e,
|
||||
0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18,
|
||||
0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f,
|
||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69,
|
||||
0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x10, 0x61, 0x6e, 0x6f,
|
||||
0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x42, 0x06, 0x0a,
|
||||
0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e,
|
||||
0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72,
|
||||
0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72,
|
||||
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x3b, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
})
|
||||
|
||||
var (
|
||||
file_proto_profile_proto_rawDescOnce sync.Once
|
||||
file_proto_profile_proto_rawDescData []byte
|
||||
file_profile_proto_rawDescOnce sync.Once
|
||||
file_profile_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_proto_profile_proto_rawDescGZIP() []byte {
|
||||
file_proto_profile_proto_rawDescOnce.Do(func() {
|
||||
file_proto_profile_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_profile_proto_rawDesc), len(file_proto_profile_proto_rawDesc)))
|
||||
func file_profile_proto_rawDescGZIP() []byte {
|
||||
file_profile_proto_rawDescOnce.Do(func() {
|
||||
file_profile_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_profile_proto_rawDesc), len(file_profile_proto_rawDesc)))
|
||||
})
|
||||
return file_proto_profile_proto_rawDescData
|
||||
return file_profile_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_profile_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||
var file_proto_profile_proto_goTypes = []any{
|
||||
(*Address)(nil), // 0: profile_messages.Address
|
||||
(*SetProfile)(nil), // 1: profile_messages.SetProfile
|
||||
(*AddAddress)(nil), // 2: profile_messages.AddAddress
|
||||
(*UpdateAddress)(nil), // 3: profile_messages.UpdateAddress
|
||||
(*RemoveAddress)(nil), // 4: profile_messages.RemoveAddress
|
||||
(*LinkCart)(nil), // 5: profile_messages.LinkCart
|
||||
(*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout
|
||||
(*LinkOrder)(nil), // 7: profile_messages.LinkOrder
|
||||
(*Mutation)(nil), // 8: profile_messages.Mutation
|
||||
var file_profile_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
||||
var file_profile_proto_goTypes = []any{
|
||||
(*Address)(nil), // 0: profile_messages.Address
|
||||
(*SetProfile)(nil), // 1: profile_messages.SetProfile
|
||||
(*AddAddress)(nil), // 2: profile_messages.AddAddress
|
||||
(*UpdateAddress)(nil), // 3: profile_messages.UpdateAddress
|
||||
(*RemoveAddress)(nil), // 4: profile_messages.RemoveAddress
|
||||
(*LinkCart)(nil), // 5: profile_messages.LinkCart
|
||||
(*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout
|
||||
(*LinkOrder)(nil), // 7: profile_messages.LinkOrder
|
||||
(*AnonymizeProfile)(nil), // 8: profile_messages.AnonymizeProfile
|
||||
(*Mutation)(nil), // 9: profile_messages.Mutation
|
||||
}
|
||||
var file_proto_profile_proto_depIdxs = []int32{
|
||||
var file_profile_proto_depIdxs = []int32{
|
||||
0, // 0: profile_messages.AddAddress.address:type_name -> profile_messages.Address
|
||||
1, // 1: profile_messages.Mutation.set_profile:type_name -> profile_messages.SetProfile
|
||||
2, // 2: profile_messages.Mutation.add_address:type_name -> profile_messages.AddAddress
|
||||
@@ -963,22 +1023,23 @@ var file_proto_profile_proto_depIdxs = []int32{
|
||||
5, // 5: profile_messages.Mutation.link_cart:type_name -> profile_messages.LinkCart
|
||||
6, // 6: profile_messages.Mutation.link_checkout:type_name -> profile_messages.LinkCheckout
|
||||
7, // 7: profile_messages.Mutation.link_order:type_name -> profile_messages.LinkOrder
|
||||
8, // [8:8] is the sub-list for method output_type
|
||||
8, // [8:8] is the sub-list for method input_type
|
||||
8, // [8:8] is the sub-list for extension type_name
|
||||
8, // [8:8] is the sub-list for extension extendee
|
||||
0, // [0:8] is the sub-list for field type_name
|
||||
8, // 8: profile_messages.Mutation.anonymize_profile:type_name -> profile_messages.AnonymizeProfile
|
||||
9, // [9:9] is the sub-list for method output_type
|
||||
9, // [9:9] is the sub-list for method input_type
|
||||
9, // [9:9] is the sub-list for extension type_name
|
||||
9, // [9:9] is the sub-list for extension extendee
|
||||
0, // [0:9] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_proto_profile_proto_init() }
|
||||
func file_proto_profile_proto_init() {
|
||||
if File_proto_profile_proto != nil {
|
||||
func init() { file_profile_proto_init() }
|
||||
func file_profile_proto_init() {
|
||||
if File_profile_proto != nil {
|
||||
return
|
||||
}
|
||||
file_proto_profile_proto_msgTypes[0].OneofWrappers = []any{}
|
||||
file_proto_profile_proto_msgTypes[1].OneofWrappers = []any{}
|
||||
file_proto_profile_proto_msgTypes[3].OneofWrappers = []any{}
|
||||
file_proto_profile_proto_msgTypes[8].OneofWrappers = []any{
|
||||
file_profile_proto_msgTypes[0].OneofWrappers = []any{}
|
||||
file_profile_proto_msgTypes[1].OneofWrappers = []any{}
|
||||
file_profile_proto_msgTypes[3].OneofWrappers = []any{}
|
||||
file_profile_proto_msgTypes[9].OneofWrappers = []any{
|
||||
(*Mutation_SetProfile)(nil),
|
||||
(*Mutation_AddAddress)(nil),
|
||||
(*Mutation_UpdateAddress)(nil),
|
||||
@@ -986,22 +1047,23 @@ func file_proto_profile_proto_init() {
|
||||
(*Mutation_LinkCart)(nil),
|
||||
(*Mutation_LinkCheckout)(nil),
|
||||
(*Mutation_LinkOrder)(nil),
|
||||
(*Mutation_AnonymizeProfile)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_profile_proto_rawDesc), len(file_proto_profile_proto_rawDesc)),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_profile_proto_rawDesc), len(file_profile_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 9,
|
||||
NumMessages: 10,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_proto_profile_proto_goTypes,
|
||||
DependencyIndexes: file_proto_profile_proto_depIdxs,
|
||||
MessageInfos: file_proto_profile_proto_msgTypes,
|
||||
GoTypes: file_profile_proto_goTypes,
|
||||
DependencyIndexes: file_profile_proto_depIdxs,
|
||||
MessageInfos: file_profile_proto_msgTypes,
|
||||
}.Build()
|
||||
File_proto_profile_proto = out.File
|
||||
file_proto_profile_proto_goTypes = nil
|
||||
file_proto_profile_proto_depIdxs = nil
|
||||
File_profile_proto = out.File
|
||||
file_profile_proto_goTypes = nil
|
||||
file_profile_proto_depIdxs = nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user