cart and checkout
This commit is contained in:
@@ -19,11 +19,12 @@
|
|||||||
|
|
||||||
MODULE_PATH := git.k6n.net/mats/go-cart-actor
|
MODULE_PATH := git.k6n.net/mats/go-cart-actor
|
||||||
PROTO_DIR := proto
|
PROTO_DIR := proto
|
||||||
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto
|
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto $(PROTO_DIR)/profile.proto
|
||||||
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
||||||
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
||||||
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
|
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
|
||||||
ORDER_PROTO_DIR := $(PROTO_DIR)/order
|
ORDER_PROTO_DIR := $(PROTO_DIR)/order
|
||||||
|
PROFILE_PROTO_DIR := $(PROTO_DIR)/profile
|
||||||
|
|
||||||
# Allow override: make PROTOC=/path/to/protoc
|
# Allow override: make PROTOC=/path/to/protoc
|
||||||
PROTOC ?= protoc
|
PROTOC ?= protoc
|
||||||
@@ -88,6 +89,9 @@ protogen: check_tools
|
|||||||
--go_out=./proto/order --go_opt=paths=source_relative \
|
--go_out=./proto/order --go_opt=paths=source_relative \
|
||||||
--go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \
|
--go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \
|
||||||
$(PROTO_DIR)/order.proto
|
$(PROTO_DIR)/order.proto
|
||||||
|
$(PROTOC) -I $(PROTO_DIR) \
|
||||||
|
--go_out=./proto/profile --go_opt=paths=source_relative \
|
||||||
|
$(PROTO_DIR)/profile.proto
|
||||||
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||||
|
|
||||||
clean_proto:
|
clean_proto:
|
||||||
@@ -95,6 +99,8 @@ clean_proto:
|
|||||||
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
|
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
|
||||||
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
|
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
|
||||||
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
|
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
|
||||||
|
@rm -f $(PROTO_DIR)/order/*_grpc.pb.go $(PROTO_DIR)/order/*.pb.go
|
||||||
|
@rm -f $(PROTO_DIR)/profile/*.pb.go
|
||||||
@echo "$(GREEN)Clean complete.$(RESET)"
|
@echo "$(GREEN)Clean complete.$(RESET)"
|
||||||
|
|
||||||
verify_proto:
|
verify_proto:
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ func GetDiscovery() discovery.Discovery {
|
|||||||
log.Fatalf("Error creating client: %v\n", err)
|
log.Fatalf("Error creating client: %v\n", err)
|
||||||
}
|
}
|
||||||
timeout := int64(30)
|
timeout := int64(30)
|
||||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
// Scope discovery to this pod's namespace so it only needs a namespaced Role
|
||||||
|
// (pods: get/list/watch), not a cluster-wide ClusterRole.
|
||||||
|
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
|
||||||
LabelSelector: "actor-pool=cart",
|
LabelSelector: "actor-pool=cart",
|
||||||
TimeoutSeconds: &timeout,
|
TimeoutSeconds: &timeout,
|
||||||
})
|
})
|
||||||
|
|||||||
+74
-27
@@ -12,19 +12,21 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,13 +53,6 @@ var redisAddress = os.Getenv("REDIS_ADDRESS")
|
|||||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||||
|
|
||||||
// getEnv returns the value of the environment variable named by key, or def if unset/empty.
|
// getEnv returns the value of the environment variable named by key, or def if unset/empty.
|
||||||
func getEnv(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeListenAddr accepts either a bare port ("8080") or a full listen
|
// normalizeListenAddr accepts either a bare port ("8080") or a full listen
|
||||||
// address (":8080", "0.0.0.0:8080") and returns a value usable by http.Server.Addr.
|
// address (":8080", "0.0.0.0:8080") and returns a value usable by http.Server.Addr.
|
||||||
func normalizeListenAddr(v string) string {
|
func normalizeListenAddr(v string) string {
|
||||||
@@ -67,6 +62,14 @@ func normalizeListenAddr(v string) string {
|
|||||||
return ":" + v
|
return ":" + v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getEnv returns the value of the environment variable named by key, or def if unset/empty.
|
||||||
|
func getEnv(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
// loadUCPCartSigner loads the UCP ECDSA signing key from the path specified in
|
// loadUCPCartSigner loads the UCP ECDSA signing key from the path specified in
|
||||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||||
func loadUCPCartSigner() *ucp.SigningConfig {
|
func loadUCPCartSigner() *ucp.SigningConfig {
|
||||||
@@ -144,32 +147,76 @@ func main() {
|
|||||||
|
|
||||||
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
||||||
|
|
||||||
// rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
// Addr: redisAddress,
|
Addr: redisAddress,
|
||||||
// Password: redisPassword,
|
Password: redisPassword,
|
||||||
// DB: 0,
|
DB: 0,
|
||||||
// })
|
})
|
||||||
// inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||||
// if err != nil {
|
if err != nil {
|
||||||
// log.Fatalf("Error creating inventory service: %v\n", err)
|
log.Fatalf("Error creating inventory service: %v\n", err)
|
||||||
// }
|
}
|
||||||
|
_ = inventoryService
|
||||||
|
|
||||||
// inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||||
// if err != nil {
|
if err != nil {
|
||||||
// log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
||||||
// }
|
}
|
||||||
|
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
|
||||||
reg.RegisterProcessor(
|
reg.RegisterProcessor(
|
||||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
|
// Clear bypass flags first
|
||||||
|
for _, v := range g.Vouchers {
|
||||||
|
if v != nil {
|
||||||
|
v.BypassedByPromotions = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
g.UpdateTotals()
|
g.UpdateTotals()
|
||||||
// Evaluate active promotions against the freshly-totalled cart and apply
|
|
||||||
// any matched actions (e.g. the Volymrabatt volume discount), which adjust
|
|
||||||
// TotalPrice/TotalDiscount on top of line items and vouchers.
|
|
||||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||||
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||||
|
|
||||||
|
// Check if any qualified promotion rule has a coupon_code condition matching our vouchers
|
||||||
|
hasBypassed := false
|
||||||
|
for _, res := range results {
|
||||||
|
if res.Applicable {
|
||||||
|
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
|
||||||
|
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
|
||||||
|
var codes []string
|
||||||
|
if s, ok := bc.Value.AsString(); ok {
|
||||||
|
codes = append(codes, strings.ToLower(s))
|
||||||
|
} else if arr, ok := bc.Value.AsStringSlice(); ok {
|
||||||
|
for _, s := range arr {
|
||||||
|
codes = append(codes, strings.ToLower(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range codes {
|
||||||
|
for _, v := range g.Vouchers {
|
||||||
|
if v != nil && strings.ToLower(v.Code) == code {
|
||||||
|
if !v.BypassedByPromotions {
|
||||||
|
v.BypassedByPromotions = true
|
||||||
|
hasBypassed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasBypassed {
|
||||||
|
// Re-evaluate with bypassed vouchers
|
||||||
|
g.UpdateTotals()
|
||||||
|
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||||
|
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyResults applies qualifying actions in priority order and records
|
// ApplyResults applies qualifying actions in priority order and records
|
||||||
// every effect — both applied discounts and pending "spend X more for ..."
|
// every effect — both applied discounts and pending "spend X more for ..."
|
||||||
// nudges with their progress — in g.AppliedPromotions.
|
// nudges with their progress — in g.AppliedPromotions.
|
||||||
@@ -287,7 +334,7 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
otelShutdown, err := setupOTelSDK(ctx)
|
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
@@ -399,7 +398,6 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
|
|||||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
||||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
hostAttr := attribute.String("other host", ownerHost.Name())
|
||||||
span.SetAttributes(hostAttr)
|
span.SetAttributes(hostAttr)
|
||||||
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
|
|
||||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
||||||
|
|
||||||
@@ -419,7 +417,6 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
|
|||||||
var (
|
var (
|
||||||
tracer = otel.Tracer(name)
|
tracer = otel.Tracer(name)
|
||||||
meter = otel.Meter(name)
|
meter = otel.Meter(name)
|
||||||
logger = otelslog.NewLogger(name)
|
|
||||||
proxyCalls metric.Int64Counter
|
proxyCalls metric.Int64Counter
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||||
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
|
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
|
||||||
@@ -129,7 +130,7 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
|
|||||||
// Adyen analogue of the Klarna push. Create the event-sourced
|
// Adyen analogue of the Klarna push. Create the event-sourced
|
||||||
// order now (mirrors KlarnaPushHandler). Idempotent on
|
// order now (mirrors KlarnaPushHandler). Idempotent on
|
||||||
// "checkout-{checkoutId}", so a retried CAPTURE won't duplicate.
|
// "checkout-{checkoutId}", so a retried CAPTURE won't duplicate.
|
||||||
if isSuccess && s.orderClient != nil {
|
if isSuccess {
|
||||||
s.createAdyenOrder(r.Context(), *checkoutId, item)
|
s.createAdyenOrder(r.Context(), *checkoutId, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,12 +232,21 @@ func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId ca
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserve inventory once, guarded by the grain flag so a retried CAPTURE
|
|
||||||
// does not decrement stock twice. (Same as the Klarna path.)
|
|
||||||
if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved {
|
if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved {
|
||||||
if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil {
|
if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil {
|
||||||
log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr)
|
log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr)
|
||||||
} else {
|
} else {
|
||||||
|
// Commit step: release cart reservation since we permanently decremented stock
|
||||||
|
if s.reservationService != nil {
|
||||||
|
for _, item := range grain.CartState.Items {
|
||||||
|
if item == nil || !shouldTrackInventory(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
|
||||||
|
log.Printf("from-checkout: failed to release cart reservation for %s: %v", item.Sku, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{
|
s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{
|
||||||
Id: grain.Id.String(),
|
Id: grain.Id.String(),
|
||||||
Status: "success",
|
Status: "success",
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ type CheckoutMeta struct {
|
|||||||
//
|
//
|
||||||
// If you later need to support different tax rates per line, you can extend
|
// If you later need to support different tax rates per line, you can extend
|
||||||
// CartItem / Delivery to expose that data and propagate it here.
|
// CartItem / Delivery to expose that data and propagate it here.
|
||||||
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
// tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for
|
||||||
|
// delivery lines instead of the hardcoded 2500 (25 % as CartItem format).
|
||||||
|
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) ([]byte, *CheckoutOrder, error) {
|
||||||
if grain == nil {
|
if grain == nil {
|
||||||
return nil, nil, fmt.Errorf("nil grain")
|
return nil, nil, fmt.Errorf("nil grain")
|
||||||
}
|
}
|
||||||
@@ -106,12 +108,32 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasFreeShipping := false
|
||||||
|
if grain.CartState != nil {
|
||||||
|
for _, ap := range grain.CartState.AppliedPromotions {
|
||||||
|
if ap.Type == "free_shipping" && !ap.Pending {
|
||||||
|
hasFreeShipping = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
total := cart.NewPrice()
|
total := cart.NewPrice()
|
||||||
total.Add(*grain.CartState.TotalPrice)
|
total.Add(*grain.CartState.TotalPrice)
|
||||||
|
|
||||||
// Delivery lines
|
// Delivery lines — use the configured default tax rate for the purchase country.
|
||||||
|
defaultKlarnaRate := defaultKlarnaTaxRate(tp, country)
|
||||||
for _, d := range grain.Deliveries {
|
for _, d := range grain.Deliveries {
|
||||||
if d == nil || d.Price.IncVat <= 0 {
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unitPrice := d.Price.IncVat
|
||||||
|
taxAmount := d.Price.TotalVat()
|
||||||
|
if hasFreeShipping {
|
||||||
|
unitPrice = 0
|
||||||
|
taxAmount = 0
|
||||||
|
}
|
||||||
|
if unitPrice <= 0 && !hasFreeShipping {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
//total.Add(d.Price)
|
//total.Add(d.Price)
|
||||||
@@ -120,11 +142,11 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
Reference: d.Provider,
|
Reference: d.Provider,
|
||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: int(d.Price.IncVat),
|
UnitPrice: int(unitPrice),
|
||||||
TaxRate: 2500,
|
TaxRate: defaultKlarnaRate,
|
||||||
QuantityUnit: "st",
|
QuantityUnit: "st",
|
||||||
TotalAmount: int(d.Price.IncVat),
|
TotalAmount: int(unitPrice),
|
||||||
TotalTaxAmount: int(d.Price.TotalVat()),
|
TotalTaxAmount: int(taxAmount),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +176,24 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
return payload, order, nil
|
return payload, order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultKlarnaTaxRate returns the default tax rate for the purchase country, in Klarna
|
||||||
|
// format (percent x 100, e.g. 2500 = 25.00 %). When tp is nil, falls back to 2500 (25 %).
|
||||||
|
func defaultKlarnaTaxRate(tp cart.TaxProvider, country string) int {
|
||||||
|
if tp == nil {
|
||||||
|
return 2500
|
||||||
|
}
|
||||||
|
return tp.DefaultTaxRate(country) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultAdyenTaxRate returns the default tax rate for the purchase country, in Adyen
|
||||||
|
// format (raw percent, e.g. 25 = 25 %). When tp is nil, falls back to 25.
|
||||||
|
func defaultAdyenTaxRate(tp cart.TaxProvider, country string) int64 {
|
||||||
|
if tp == nil {
|
||||||
|
return 25
|
||||||
|
}
|
||||||
|
return int64(tp.DefaultTaxRate(country))
|
||||||
|
}
|
||||||
|
|
||||||
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
||||||
host := getOriginalHost(r)
|
host := getOriginalHost(r)
|
||||||
country := getCountryFromHost(host)
|
country := getCountryFromHost(host)
|
||||||
@@ -171,7 +211,7 @@ func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
|
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
|
||||||
if grain == nil {
|
if grain == nil {
|
||||||
return nil, fmt.Errorf("nil grain")
|
return nil, fmt.Errorf("nil grain")
|
||||||
}
|
}
|
||||||
@@ -204,20 +244,40 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
TaxPercentage: common.PtrInt64(int64(it.Tax)),
|
TaxPercentage: common.PtrInt64(int64(it.Tax)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
hasFreeShipping := false
|
||||||
|
if grain.CartState != nil {
|
||||||
|
for _, ap := range grain.CartState.AppliedPromotions {
|
||||||
|
if ap.Type == "free_shipping" && !ap.Pending {
|
||||||
|
hasFreeShipping = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
total := cart.NewPrice()
|
total := cart.NewPrice()
|
||||||
total.Add(*grain.CartState.TotalPrice)
|
total.Add(*grain.CartState.TotalPrice)
|
||||||
|
|
||||||
// Delivery lines
|
// Delivery lines — use the configured default tax rate for the purchase country.
|
||||||
|
defaultAdyenRate := defaultAdyenTaxRate(tp, country)
|
||||||
for _, d := range grain.Deliveries {
|
for _, d := range grain.Deliveries {
|
||||||
if d == nil || d.Price.IncVat <= 0 {
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amountIncTax := d.Price.IncVat
|
||||||
|
amountExTax := d.Price.ValueExVat()
|
||||||
|
if hasFreeShipping {
|
||||||
|
amountIncTax = 0
|
||||||
|
amountExTax = 0
|
||||||
|
}
|
||||||
|
if amountIncTax <= 0 && !hasFreeShipping {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||||
Quantity: common.PtrInt64(1),
|
Quantity: common.PtrInt64(1),
|
||||||
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
|
AmountIncludingTax: common.PtrInt64(amountIncTax),
|
||||||
Description: common.PtrString("Delivery"),
|
Description: common.PtrString("Delivery"),
|
||||||
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
|
AmountExcludingTax: common.PtrInt64(amountExTax),
|
||||||
TaxPercentage: common.PtrInt64(25),
|
TaxPercentage: common.PtrInt64(defaultAdyenRate),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ func GetDiscovery() discovery.Discovery {
|
|||||||
log.Fatalf("Error creating client: %v\n", err)
|
log.Fatalf("Error creating client: %v\n", err)
|
||||||
}
|
}
|
||||||
timeout := int64(30)
|
timeout := int64(30)
|
||||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
// Scope discovery to this pod's namespace so it only needs a namespaced Role
|
||||||
|
// (pods: get/list/watch), not a cluster-wide ClusterRole.
|
||||||
|
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
|
||||||
LabelSelector: "actor-pool=checkout",
|
LabelSelector: "actor-pool=checkout",
|
||||||
TimeoutSeconds: &timeout,
|
TimeoutSeconds: &timeout,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -193,6 +193,18 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
|
|||||||
logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway",
|
logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway",
|
||||||
"err", err, "checkoutId", grain.Id.String())
|
"err", err, "checkoutId", grain.Id.String())
|
||||||
invStatus = "failed"
|
invStatus = "failed"
|
||||||
|
} else {
|
||||||
|
// Commit step: successfully decremented stock permanently, now release the temporary cart reservations
|
||||||
|
if s.reservationService != nil {
|
||||||
|
for _, item := range grain.CartState.Items {
|
||||||
|
if item == nil || !shouldTrackInventory(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.reservationService.ReleaseForCart(r.Context(), inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
|
||||||
|
logger.WarnContext(r.Context(), "klarna push: failed to release cart reservation", "sku", item.Sku, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
|
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
|
||||||
Id: grain.Id.String(),
|
Id: grain.Id.String(),
|
||||||
@@ -209,22 +221,12 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
|
|||||||
CompletedAt: timestamppb.Now(),
|
CompletedAt: timestamppb.Now(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Create the event-sourced order grain (dual-write with AMQP) ────
|
// ── Create the event-sourced order grain (HTTP with AMQP fallback) ────
|
||||||
if s.orderClient != nil {
|
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
|
||||||
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
|
// Non-fatal: the checkout grain is updated, the order can be
|
||||||
// Non-fatal: the checkout grain is updated, the order can be
|
// created on retry (idempotency key guards against duplicates).
|
||||||
// created on retry (idempotency key guards against duplicates).
|
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
|
||||||
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
|
"err", orderErr, "checkoutId", grain.Id.String())
|
||||||
"err", orderErr, "checkoutId", grain.Id.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dual-write: AMQP order-queue for legacy consumers ───────────────
|
|
||||||
if s.orderHandler != nil {
|
|
||||||
legacyBody, _ := buildLegacyOrderJSON(grain, order, "klarna", orderId)
|
|
||||||
if pubErr := s.orderHandler.OrderCompleted(legacyBody); pubErr != nil {
|
|
||||||
logger.WarnContext(r.Context(), "order-queue publish failed", "err", pubErr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
||||||
@@ -264,59 +266,7 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildLegacyOrderJSON builds the go-order-manager compatible JSON for AMQP
|
|
||||||
// dual-write, so existing downstream consumers on the order-queue still receive
|
|
||||||
// order events during the migration period.
|
|
||||||
func buildLegacyOrderJSON(grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string, orderId string) ([]byte, error) {
|
|
||||||
if grain.CartState == nil {
|
|
||||||
return nil, fmt.Errorf("buildLegacyOrderJSON: checkout %s has no cart state", grain.Id)
|
|
||||||
}
|
|
||||||
type legacyLine struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
UnitPrice int `json:"unit_price"`
|
|
||||||
TaxRate int `json:"tax_rate"`
|
|
||||||
TotalAmount int `json:"total_amount"`
|
|
||||||
TotalTaxAmount int `json:"total_tax_amount"`
|
|
||||||
}
|
|
||||||
type legacyOrder struct {
|
|
||||||
ID string `json:"order_id"`
|
|
||||||
PurchaseCountry string `json:"purchase_country"`
|
|
||||||
PurchaseCurrency string `json:"purchase_currency"`
|
|
||||||
Locale string `json:"locale"`
|
|
||||||
OrderAmount int `json:"order_amount"`
|
|
||||||
OrderTaxAmount int `json:"order_tax_amount"`
|
|
||||||
OrderLines []legacyLine `json:"order_lines"`
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := make([]legacyLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
|
||||||
for _, it := range grain.CartState.Items {
|
|
||||||
if it == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, legacyLine{
|
|
||||||
Reference: it.Sku,
|
|
||||||
Name: it.Meta.Name,
|
|
||||||
Quantity: int(it.Quantity),
|
|
||||||
UnitPrice: int(it.Price.IncVat),
|
|
||||||
TaxRate: it.Tax,
|
|
||||||
TotalAmount: int(it.TotalPrice.IncVat),
|
|
||||||
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
lo := legacyOrder{
|
|
||||||
ID: orderId,
|
|
||||||
PurchaseCountry: klarnaOrder.PurchaseCountry,
|
|
||||||
PurchaseCurrency: klarnaOrder.PurchaseCurrency,
|
|
||||||
Locale: klarnaOrder.Locale,
|
|
||||||
OrderAmount: klarnaOrder.OrderAmount,
|
|
||||||
OrderTaxAmount: klarnaOrder.OrderTaxAmount,
|
|
||||||
OrderLines: lines,
|
|
||||||
}
|
|
||||||
return json.Marshal(lo)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||||
if item.StoreId == nil || *item.StoreId == "" {
|
if item.StoreId == nil || *item.StoreId == "" {
|
||||||
@@ -325,10 +275,20 @@ func getLocationId(item *cart.CartItem) inventory.LocationID {
|
|||||||
return inventory.LocationID(*item.StoreId)
|
return inventory.LocationID(*item.StoreId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldTrackInventory(item *cart.CartItem) bool {
|
||||||
|
if item.DropShip {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if item.InventoryTracked {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return item.Cgm == "55010"
|
||||||
|
}
|
||||||
|
|
||||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
||||||
var requests []inventory.ReserveRequest
|
var requests []inventory.ReserveRequest
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item == nil {
|
if item == nil || !shouldTrackInventory(item) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
requests = append(requests, inventory.ReserveRequest{
|
requests = append(requests, inventory.ReserveRequest{
|
||||||
|
|||||||
+40
-5
@@ -12,14 +12,16 @@ import (
|
|||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||||
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
@@ -49,6 +51,27 @@ var redisAddress = os.Getenv("REDIS_ADDRESS")
|
|||||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||||
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
|
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
|
||||||
|
|
||||||
|
// selectTaxProvider picks the tax provider from the environment.
|
||||||
|
// Default is NordicTaxProvider with SE as the default country.
|
||||||
|
// Set TAX_PROVIDER=static for dev and tests (no country awareness).
|
||||||
|
func selectTaxProvider() cart.TaxProvider {
|
||||||
|
switch os.Getenv("TAX_PROVIDER") {
|
||||||
|
case "static":
|
||||||
|
return cart.NewStaticTaxProvider()
|
||||||
|
case "nordic":
|
||||||
|
return cart.NewNordicTaxProvider(envOrDefault("TAX_DEFAULT_COUNTRY", "SE"))
|
||||||
|
default:
|
||||||
|
return cart.NewNordicTaxProvider(envOrDefault("TAX_DEFAULT_COUNTRY", "SE"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
// loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in
|
// loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in
|
||||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||||
func loadUCPCheckoutSigner() *ucp.SigningConfig {
|
func loadUCPCheckoutSigner() *ucp.SigningConfig {
|
||||||
@@ -90,12 +113,17 @@ func main() {
|
|||||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
log.Fatalf("Error creating inventory service: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating reservation service: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
checkoutDir := os.Getenv("CHECKOUT_DIR")
|
checkoutDir := os.Getenv("CHECKOUT_DIR")
|
||||||
if checkoutDir == "" {
|
if checkoutDir == "" {
|
||||||
checkoutDir = "data"
|
checkoutDir = "data"
|
||||||
}
|
}
|
||||||
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
|
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
|
||||||
|
var syncedServer *CheckoutPoolServer
|
||||||
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
||||||
MutationRegistry: reg,
|
MutationRegistry: reg,
|
||||||
Storage: diskStorage,
|
Storage: diskStorage,
|
||||||
@@ -113,6 +141,11 @@ func main() {
|
|||||||
return ret, nil
|
return ret, nil
|
||||||
},
|
},
|
||||||
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
|
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
state, err := grain.GetCurrentState()
|
||||||
|
if err == nil && state != nil {
|
||||||
|
syncedServer.releaseCartReservations(ctx, state)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
|
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
|
||||||
@@ -153,8 +186,10 @@ func main() {
|
|||||||
log.Fatalf("failed to declare order queue: %v", err)
|
log.Fatalf("failed to declare order queue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
|
syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
|
||||||
syncedServer.inventoryService = inventoryService
|
syncedServer.inventoryService = inventoryService
|
||||||
|
syncedServer.reservationService = reservationService
|
||||||
|
syncedServer.taxProvider = selectTaxProvider()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
debugMux := http.NewServeMux()
|
debugMux := http.NewServeMux()
|
||||||
@@ -174,7 +209,7 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
otelShutdown, err := setupOTelSDK(ctx)
|
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
@@ -27,6 +28,16 @@ type settledPayment struct {
|
|||||||
// selections into from-checkout order lines. Shared by every provider so the
|
// selections into from-checkout order lines. Shared by every provider so the
|
||||||
// order payload is built the same way no matter who settled the payment.
|
// order payload is built the same way no matter who settled the payment.
|
||||||
func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
||||||
|
hasFreeShipping := false
|
||||||
|
if grain.CartState != nil {
|
||||||
|
for _, ap := range grain.CartState.AppliedPromotions {
|
||||||
|
if ap.Type == "free_shipping" && !ap.Pending {
|
||||||
|
hasFreeShipping = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
||||||
for _, it := range grain.CartState.Items {
|
for _, it := range grain.CartState.Items {
|
||||||
if it == nil {
|
if it == nil {
|
||||||
@@ -38,11 +49,19 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
|||||||
Name: it.Meta.Name,
|
Name: it.Meta.Name,
|
||||||
Quantity: int32(it.Quantity),
|
Quantity: int32(it.Quantity),
|
||||||
UnitPrice: it.Price.IncVat,
|
UnitPrice: it.Price.IncVat,
|
||||||
TaxRate: int32(it.Tax),
|
// CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
|
||||||
|
TaxRate: int32(it.Tax / 100),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, d := range grain.Deliveries {
|
for _, d := range grain.Deliveries {
|
||||||
if d == nil || d.Price.IncVat <= 0 {
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unitPrice := d.Price.IncVat
|
||||||
|
if hasFreeShipping {
|
||||||
|
unitPrice = 0
|
||||||
|
}
|
||||||
|
if unitPrice <= 0 && !hasFreeShipping {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lines = append(lines, OrderLine{
|
lines = append(lines, OrderLine{
|
||||||
@@ -50,10 +69,35 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
|||||||
Sku: d.Provider,
|
Sku: d.Provider,
|
||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: d.Price.IncVat,
|
UnitPrice: unitPrice,
|
||||||
TaxRate: 2500,
|
TaxRate: 25,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflected applied promotions as negative discount lines
|
||||||
|
if grain.CartState != nil {
|
||||||
|
for _, ap := range grain.CartState.AppliedPromotions {
|
||||||
|
if ap.Pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discountVal := int64(0)
|
||||||
|
if ap.Discount != nil {
|
||||||
|
discountVal = ap.Discount.IncVat
|
||||||
|
}
|
||||||
|
if discountVal <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, OrderLine{
|
||||||
|
Reference: "promo-" + ap.PromotionId,
|
||||||
|
Sku: "promo-" + ap.PromotionId,
|
||||||
|
Name: "Promotion: " + ap.Name,
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: -discountVal,
|
||||||
|
TaxRate: 25, // default standard tax rate for promotion discount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +108,10 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
|||||||
//
|
//
|
||||||
// It is idempotent: the order service dedupes on "checkout-{checkoutId}", so
|
// It is idempotent: the order service dedupes on "checkout-{checkoutId}", so
|
||||||
// repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the
|
// repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the
|
||||||
// existing order rather than creating a duplicate. Callers should treat a
|
// existing order rather than creating a duplicate.
|
||||||
// non-nil error as non-fatal and retry on the next callback.
|
//
|
||||||
|
// If the HTTP call fails or is not configured, it falls back to publishing the
|
||||||
|
// exact same payload to RabbitMQ via s.orderHandler.OrderCompleted(body).
|
||||||
func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) {
|
func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) {
|
||||||
if grain.CartState == nil {
|
if grain.CartState == nil {
|
||||||
return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id)
|
return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id)
|
||||||
@@ -96,15 +142,41 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
|
|||||||
req.Payment.Reference = pay.Reference
|
req.Payment.Reference = pay.Reference
|
||||||
req.Payment.Amount = pay.Amount
|
req.Payment.Amount = pay.Amount
|
||||||
|
|
||||||
result, err := s.orderClient.CreateOrder(ctx, req, "")
|
var createErr error
|
||||||
if err != nil {
|
if s.orderClient != nil {
|
||||||
return nil, err
|
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{
|
// Fallback to AMQP
|
||||||
OrderId: result.OrderId,
|
if s.orderHandler != nil {
|
||||||
Status: "completed",
|
body, err := json.Marshal(req)
|
||||||
CreatedAt: timestamppb.Now(),
|
if err != nil {
|
||||||
})
|
return nil, fmt.Errorf("failed to marshal order fallback: %w", err)
|
||||||
return result, nil
|
}
|
||||||
|
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
|
cartClient *CartClient
|
||||||
orderClient *OrderClient
|
orderClient *OrderClient
|
||||||
orderHandler *AmqpOrderHandler
|
orderHandler *AmqpOrderHandler
|
||||||
inventoryService *inventory.RedisInventoryService
|
inventoryService *inventory.RedisInventoryService
|
||||||
|
reservationService *inventory.RedisCartReservationService
|
||||||
|
taxProvider cart.TaxProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer {
|
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer {
|
||||||
@@ -207,6 +209,10 @@ func (s *CheckoutPoolServer) CancelPaymentHandler(w http.ResponseWriter, r *http
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if grain, gerr := s.Get(r.Context(), uint64(checkoutId)); gerr == nil && grain != nil {
|
||||||
|
s.releaseCartReservations(r.Context(), grain)
|
||||||
|
}
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
return s.WriteResult(w, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +301,7 @@ func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *chec
|
|||||||
|
|
||||||
meta := GetCheckoutMetaFromRequest(r)
|
meta := GetCheckoutMetaFromRequest(r)
|
||||||
|
|
||||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
payload, _, err := BuildCheckoutOrderPayload(grain, meta, s.taxProvider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -431,7 +437,7 @@ func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.
|
|||||||
switch payload.Provider {
|
switch payload.Provider {
|
||||||
case "adyen":
|
case "adyen":
|
||||||
meta := GetCheckoutMetaFromRequest(r)
|
meta := GetCheckoutMetaFromRequest(r)
|
||||||
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
|
sessionData, err := BuildAdyenCheckoutSession(grain, meta, s.taxProvider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("unable to build adyen session", "error", err)
|
logger.Error("unable to build adyen session", "error", err)
|
||||||
return err
|
return err
|
||||||
@@ -536,10 +542,10 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
|
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
|
||||||
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
|
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
|
||||||
handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler)))
|
handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler)))
|
||||||
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
|
handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
|
||||||
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
||||||
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
||||||
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
||||||
|
|
||||||
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
|
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
|
||||||
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
|
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
|
||||||
@@ -547,3 +553,17 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
|
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *CheckoutPoolServer) releaseCartReservations(ctx context.Context, grain *checkout.CheckoutGrain) {
|
||||||
|
if s.reservationService == nil || grain.CartState == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range grain.CartState.Items {
|
||||||
|
if item == nil || !shouldTrackInventory(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
|
||||||
|
logger.WarnContext(ctx, "failed to release cart reservation", "sku", item.Sku, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
centralStock = 0
|
centralStock = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
sku, ok := item.GetStringFieldValue("sku")
|
sku, ok := item.GetStringFieldValue("sku")
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("unable to parse central stock for item %s: %v", sku)
|
log.Printf("unable to parse central stock for item: sku missing")
|
||||||
centralStock = 0
|
centralStock = 0
|
||||||
} else {
|
} else {
|
||||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
|
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
|
||||||
|
|||||||
+22
-105
@@ -3,13 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"hash/fnv"
|
|
||||||
"log/slog"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,135 +69,56 @@ func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// This ingester lets the order service supersede the legacy go-order-manager:
|
// ingestOrderFromQueue records one order from the queue. It uses the exact same
|
||||||
// it consumes the same "order-queue" the Klarna/Adyen checkout publishes to and
|
// idempotent business logic as the HTTP endpoint.
|
||||||
// records each completed order as an event-sourced grain. Those orders are
|
func ingestOrderFromQueue(ctx context.Context, s *server, body []byte) error {
|
||||||
// already paid by the external processor, so we record payment with provider
|
var req fromCheckoutReq
|
||||||
// "legacy" (place -> authorize -> capture) rather than charging again.
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
|
||||||
// legacyOrder is the subset of go-order-manager's Order JSON we need.
|
|
||||||
type legacyOrder struct {
|
|
||||||
ID string `json:"order_id"`
|
|
||||||
PurchaseCountry string `json:"purchase_country"`
|
|
||||||
PurchaseCurrency string `json:"purchase_currency"`
|
|
||||||
Locale string `json:"locale"`
|
|
||||||
OrderAmount int64 `json:"order_amount"`
|
|
||||||
OrderTaxAmount int64 `json:"order_tax_amount"`
|
|
||||||
OrderLines []legacyLine `json:"order_lines"`
|
|
||||||
Customer *legacyPerson `json:"customer,omitempty"`
|
|
||||||
BillingAddress *legacyAddr `json:"billing_address,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type legacyLine struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Quantity int32 `json:"quantity"`
|
|
||||||
UnitPrice int64 `json:"unit_price"`
|
|
||||||
TaxRate int32 `json:"tax_rate"`
|
|
||||||
TotalAmount int64 `json:"total_amount"`
|
|
||||||
TotalTaxAmount int64 `json:"total_tax_amount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type legacyPerson struct {
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
}
|
|
||||||
type legacyAddr struct {
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
GivenName string `json:"given_name,omitempty"`
|
|
||||||
FamilyName string `json:"family_name,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// legacyOrderID maps a legacy string order id to a stable grain id, so a
|
|
||||||
// redelivered message resolves to the same grain (PlaceOrder then no-ops).
|
|
||||||
func legacyOrderID(ref string) uint64 {
|
|
||||||
h := fnv.New64a()
|
|
||||||
_, _ = h.Write([]byte(ref))
|
|
||||||
id := h.Sum64()
|
|
||||||
if id == 0 {
|
|
||||||
id = 1
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// ingestLegacyOrder records one legacy order as an event-sourced grain. It is
|
|
||||||
// idempotent: if the order is already placed, PlaceOrder is rejected and we
|
|
||||||
// stop (the order already exists). Testable without a broker.
|
|
||||||
func ingestLegacyOrder(ctx context.Context, app *orderedApplier, body []byte) error {
|
|
||||||
var lo legacyOrder
|
|
||||||
if err := json.Unmarshal(body, &lo); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if lo.ID == "" || len(lo.OrderLines) == 0 {
|
if len(req.Lines) == 0 {
|
||||||
return nil // nothing actionable
|
return nil // nothing actionable
|
||||||
}
|
}
|
||||||
id := legacyOrderID(lo.ID)
|
|
||||||
ms := time.Now().UnixMilli()
|
|
||||||
|
|
||||||
po := &messages.PlaceOrder{
|
_, _, _, _, runErr, err := s.createOrderFromCheckoutInternal(ctx, &req)
|
||||||
OrderReference: lo.ID,
|
if err != nil {
|
||||||
Currency: lo.PurchaseCurrency,
|
return err
|
||||||
Locale: lo.Locale,
|
|
||||||
Country: lo.PurchaseCountry,
|
|
||||||
TotalAmount: lo.OrderAmount,
|
|
||||||
TotalTax: lo.OrderTaxAmount,
|
|
||||||
PlacedAtMs: ms,
|
|
||||||
}
|
}
|
||||||
if lo.Customer != nil {
|
if runErr != nil {
|
||||||
po.CustomerEmail = lo.Customer.Email
|
return runErr
|
||||||
}
|
}
|
||||||
if po.CustomerEmail == "" && lo.BillingAddress != nil {
|
|
||||||
po.CustomerEmail = lo.BillingAddress.Email
|
|
||||||
po.CustomerName = lo.BillingAddress.GivenName + " " + lo.BillingAddress.FamilyName
|
|
||||||
}
|
|
||||||
for _, l := range lo.OrderLines {
|
|
||||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
|
||||||
Reference: l.Reference, Sku: l.Reference, Name: l.Name,
|
|
||||||
Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate,
|
|
||||||
TotalAmount: l.TotalAmount, TotalTax: l.TotalTaxAmount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := applyOne(ctx, app, id, po); err != nil {
|
|
||||||
// Already placed (redelivery) or invalid — nothing more to do.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Record the externally-settled payment so the grain reaches "captured".
|
|
||||||
ref := "legacy-" + lo.ID
|
|
||||||
_ = applyOne(ctx, app, id, &messages.AuthorizePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
|
|
||||||
_ = applyOne(ctx, app, id, &messages.CapturePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startLegacyIngest connects to AMQP and consumes "order-queue" until ctx is
|
// startOrderIngest connects to AMQP and consumes "order-queue" until ctx is
|
||||||
// done. Best-effort: a connection failure is logged and ingestion is skipped
|
// done.
|
||||||
// (the HTTP checkout path keeps working regardless).
|
func startOrderIngest(ctx context.Context, amqpURL string, s *server) {
|
||||||
func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier, logger *slog.Logger) {
|
|
||||||
conn, err := amqp.Dial(amqpURL)
|
conn, err := amqp.Dial(amqpURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("legacy order ingest disabled: amqp dial failed", "err", err)
|
s.logger.Warn("order ingest disabled: amqp dial failed", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ch, err := conn.Channel()
|
ch, err := conn.Channel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("legacy order ingest disabled: channel failed", "err", err)
|
s.logger.Warn("order ingest disabled: channel failed", "err", err)
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
|
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("legacy order ingest disabled: queue declare failed", "err", err)
|
s.logger.Warn("order ingest disabled: queue declare failed", "err", err)
|
||||||
_ = ch.Close()
|
_ = ch.Close()
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
|
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("legacy order ingest disabled: consume failed", "err", err)
|
s.logger.Warn("order ingest disabled: consume failed", "err", err)
|
||||||
_ = ch.Close()
|
_ = ch.Close()
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.Info("ingesting legacy orders from order-queue")
|
s.logger.Info("ingesting orders from order-queue")
|
||||||
go func() {
|
go func() {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
defer ch.Close()
|
defer ch.Close()
|
||||||
@@ -211,11 +128,11 @@ func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier,
|
|||||||
return
|
return
|
||||||
case d, ok := <-msgs:
|
case d, ok := <-msgs:
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Warn("legacy order ingest: channel closed")
|
s.logger.Warn("order ingest: channel closed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := ingestLegacyOrder(ctx, app, d.Body); err != nil {
|
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
|
||||||
logger.Error("legacy order ingest failed", "err", err)
|
s.logger.Error("order queue ingest failed", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-12
@@ -3,14 +3,19 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testApplier(t *testing.T) *orderedApplier {
|
func testServer(t *testing.T) *server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
reg := actor.NewMutationRegistry()
|
reg := actor.NewMutationRegistry()
|
||||||
order.RegisterMutations(reg)
|
order.RegisterMutations(reg)
|
||||||
@@ -32,23 +37,56 @@ func testApplier(t *testing.T) *orderedApplier {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(pool.Close)
|
t.Cleanup(pool.Close)
|
||||||
return &orderedApplier{pool: pool, storage: storage}
|
|
||||||
|
applier := &orderedApplier{pool: pool, storage: storage}
|
||||||
|
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
|
||||||
|
|
||||||
|
freg := flow.NewRegistry()
|
||||||
|
flow.RegisterBuiltinHooks(freg)
|
||||||
|
order.RegisterFlowActions(freg, applier, provider)
|
||||||
|
order.RegisterEmitHook(freg, nil)
|
||||||
|
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
|
||||||
|
def, err := order.EmbeddedFlow("place-and-pay")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestIngestOrderFromQueue(t *testing.T) {
|
||||||
app := testApplier(t)
|
s := testServer(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
body := []byte(`{
|
body := []byte(`{
|
||||||
"order_id":"K-1","purchase_currency":"SEK","purchase_country":"se","locale":"sv-SE",
|
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
|
||||||
"order_amount":15000,"order_tax_amount":3000,
|
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
|
||||||
"order_lines":[{"reference":"l1","name":"Widget","quantity":1,"unit_price":15000,"tax_rate":25,"total_amount":15000,"total_tax_amount":3000}]
|
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
|
||||||
|
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
if err := ingestLegacyOrder(ctx, app, body); err != nil {
|
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||||
t.Fatalf("ingest: %v", err)
|
t.Fatalf("ingest: %v", err)
|
||||||
}
|
}
|
||||||
id := legacyOrderID("K-1")
|
|
||||||
g, err := app.Get(ctx, id)
|
// Retrieve the created order grain. The new order ID is recorded in idempotency.Store
|
||||||
|
id, ok := s.idem.Get("checkout-K-1")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected order ID to be stored in idempotency cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := s.applier.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -60,10 +98,10 @@ func TestIngestLegacyOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redelivery must be idempotent — no double capture, no new payment.
|
// Redelivery must be idempotent — no double capture, no new payment.
|
||||||
if err := ingestLegacyOrder(ctx, app, body); err != nil {
|
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||||
t.Fatalf("re-ingest: %v", err)
|
t.Fatalf("re-ingest: %v", err)
|
||||||
}
|
}
|
||||||
g2, _ := app.Get(ctx, id)
|
g2, _ := s.applier.Get(ctx, id)
|
||||||
if g2.CapturedAmount != 15000 {
|
if g2.CapturedAmount != 15000 {
|
||||||
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
|
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-4
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
@@ -100,7 +101,7 @@ type fulfillReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
||||||
id, _, ok := s.loadOrder(w, r)
|
id, g, ok := s.loadOrder(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,11 +111,72 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fid, _ := order.NewOrderId()
|
fid, _ := order.NewOrderId()
|
||||||
|
|
||||||
|
carrier := req.Carrier
|
||||||
|
trackingNumber := req.TrackingNumber
|
||||||
|
trackingURI := req.TrackingURI
|
||||||
|
|
||||||
|
// Integration with go-shipping:
|
||||||
|
if carrier == "" && g.CartId != "" {
|
||||||
|
shippingURL := os.Getenv("SHIPPING_URL")
|
||||||
|
if shippingURL == "" {
|
||||||
|
shippingURL = "http://localhost:8080"
|
||||||
|
}
|
||||||
|
// Query go-shipping for cached options
|
||||||
|
url := fmt.Sprintf("%s/api/shipping-options/%s", shippingURL, g.CartId)
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err == nil {
|
||||||
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
if err == nil && resp.StatusCode == http.StatusOK {
|
||||||
|
var groups []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
DefaultOption *struct {
|
||||||
|
BookingInstructions struct {
|
||||||
|
DeliveryOptionID string `json:"deliveryOptionId"`
|
||||||
|
ServiceCode string `json:"serviceCode"`
|
||||||
|
} `json:"bookingInstructions"`
|
||||||
|
DescriptiveTexts struct {
|
||||||
|
Checkout struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"checkout"`
|
||||||
|
} `json:"descriptiveTexts"`
|
||||||
|
} `json:"defaultOption"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(resp.Body).Decode(&groups) == nil && len(groups) > 0 {
|
||||||
|
g0 := groups[0]
|
||||||
|
carrier = g0.Type
|
||||||
|
if g0.DefaultOption != nil {
|
||||||
|
opt := g0.DefaultOption
|
||||||
|
if opt.DescriptiveTexts.Checkout.Title != "" {
|
||||||
|
carrier = opt.DescriptiveTexts.Checkout.Title
|
||||||
|
}
|
||||||
|
trackingNumber = "SE-" + opt.BookingInstructions.DeliveryOptionID
|
||||||
|
trackingURI = "https://www.postnord.se/en/our-tools/track-and-trace?shipmentId=" + trackingNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if carrier == "" {
|
||||||
|
carrier = "postnord"
|
||||||
|
}
|
||||||
|
if trackingNumber == "" {
|
||||||
|
trackingNumber = "mock-track-" + fid.String()
|
||||||
|
}
|
||||||
|
if trackingURI == "" {
|
||||||
|
trackingURI = "https://tracking.postnord.com/?id=" + trackingNumber
|
||||||
|
}
|
||||||
|
|
||||||
msg := &messages.CreateFulfillment{
|
msg := &messages.CreateFulfillment{
|
||||||
Id: "f_" + fid.String(),
|
Id: "f_" + fid.String(),
|
||||||
Carrier: req.Carrier,
|
Carrier: carrier,
|
||||||
TrackingNumber: req.TrackingNumber,
|
TrackingNumber: trackingNumber,
|
||||||
TrackingUri: req.TrackingURI,
|
TrackingUri: trackingURI,
|
||||||
AtMs: nowMs(),
|
AtMs: nowMs(),
|
||||||
}
|
}
|
||||||
for _, l := range req.Lines {
|
for _, l := range req.Lines {
|
||||||
@@ -123,6 +185,86 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.applyAndRespond(w, r, id, msg)
|
s.applyAndRespond(w, r, id, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type exchangeReq struct {
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
ReturnLines []struct {
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
Quantity int32 `json:"quantity"`
|
||||||
|
} `json:"returnLines"`
|
||||||
|
NewLines []struct {
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Quantity int32 `json:"quantity"`
|
||||||
|
UnitPrice int64 `json:"unitPrice"`
|
||||||
|
TaxRate int32 `json:"taxRate"`
|
||||||
|
TotalAmount int64 `json:"totalAmount"`
|
||||||
|
TotalTax int64 `json:"totalTax"`
|
||||||
|
} `json:"newLines"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _, ok := s.loadOrder(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req exchangeReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exId, _ := order.NewOrderId()
|
||||||
|
retId, _ := order.NewOrderId()
|
||||||
|
msg := &messages.RequestExchange{
|
||||||
|
Id: "ex_" + exId.String(),
|
||||||
|
ReturnId: "r_" + retId.String(),
|
||||||
|
Reason: req.Reason,
|
||||||
|
AtMs: nowMs(),
|
||||||
|
}
|
||||||
|
for _, l := range req.ReturnLines {
|
||||||
|
msg.ReturnLines = append(msg.ReturnLines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
||||||
|
}
|
||||||
|
for _, l := range req.NewLines {
|
||||||
|
msg.NewLines = append(msg.NewLines, &messages.OrderLine{
|
||||||
|
Reference: l.Reference,
|
||||||
|
Sku: l.Sku,
|
||||||
|
Name: l.Name,
|
||||||
|
Quantity: l.Quantity,
|
||||||
|
UnitPrice: l.UnitPrice,
|
||||||
|
TaxRate: l.TaxRate,
|
||||||
|
TotalAmount: l.TotalAmount,
|
||||||
|
TotalTax: l.TotalTax,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.applyAndRespond(w, r, id, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
type editDetailsReq struct {
|
||||||
|
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
|
||||||
|
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
|
||||||
|
ShippingPrice int64 `json:"shippingPrice,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _, ok := s.loadOrder(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req editDetailsReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := &messages.EditOrderDetails{
|
||||||
|
ShippingAddress: req.ShippingAddress,
|
||||||
|
BillingAddress: req.BillingAddress,
|
||||||
|
ShippingPrice: req.ShippingPrice,
|
||||||
|
AtMs: nowMs(),
|
||||||
|
}
|
||||||
|
s.applyAndRespond(w, r, id, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
|
||||||
id, _, ok := s.loadOrder(w, r)
|
id, _, ok := s.loadOrder(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,7 +19,7 @@ import (
|
|||||||
type fromCheckoutReq struct {
|
type fromCheckoutReq struct {
|
||||||
CheckoutId string `json:"checkoutId"`
|
CheckoutId string `json:"checkoutId"`
|
||||||
IdempotencyKey string `json:"idempotencyKey"`
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
Flow string `json:"flow,omitempty"`
|
Flow string `json:"flow,omitempty"`
|
||||||
|
|
||||||
CartId string `json:"cartId"`
|
CartId string `json:"cartId"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
@@ -55,101 +56,95 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Idempotency check ─────────────────────────────────────────────
|
ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req)
|
||||||
// Lock the key for the whole check→create→record sequence so a concurrent
|
if err != nil {
|
||||||
// retry with the same key can't race past the check and create a second
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
// order. The store is durable, so this also holds across a restart.
|
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 != "" {
|
if req.IdempotencyKey != "" {
|
||||||
unlock := s.idem.Lock(req.IdempotencyKey)
|
unlock := s.idem.Lock(req.IdempotencyKey)
|
||||||
defer unlock()
|
defer unlock()
|
||||||
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
|
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
|
||||||
// The order already exists — return it.
|
g, err := s.applier.Get(ctx, existingID)
|
||||||
g, err := s.applier.Get(r.Context(), existingID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err))
|
return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusConflict, map[string]any{
|
return existingID, nil, g, true, nil, nil
|
||||||
"orderId": order.OrderId(existingID).String(),
|
|
||||||
"order": g,
|
|
||||||
"existing": true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create the order grain ────────────────────────────────────────
|
|
||||||
id, err := order.NewOrderId()
|
id, err := order.NewOrderId()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("new order id: %w", err))
|
return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
ordID := uint64(id)
|
ordID := uint64(id)
|
||||||
|
|
||||||
// Build PlaceOrder from the same lineReq format the direct checkout uses.
|
po := buildFromCheckoutPlaceOrder(id, req)
|
||||||
po := buildFromCheckoutPlaceOrder(id, &req)
|
|
||||||
|
|
||||||
// Build a passthrough provider for the externally-settled payment.
|
|
||||||
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
|
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
|
||||||
|
|
||||||
// Run the place-and-pay flow with the passthrough provider. Since the
|
|
||||||
// payment is already settled, this records the authorization and capture
|
|
||||||
// without any external call.
|
|
||||||
flowName := req.Flow
|
flowName := req.Flow
|
||||||
if flowName == "" {
|
if flowName == "" {
|
||||||
flowName = s.defaultFlow
|
flowName = s.defaultFlow
|
||||||
}
|
}
|
||||||
def, ok := s.flows.get(flowName)
|
def, ok := s.flows.get(flowName)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName))
|
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a per-request flow registry so the authorize/capture actions use
|
|
||||||
// the passthrough provider for this specific order. RegisterFlowActions
|
|
||||||
// registers all order lifecycle actions, overriding the default provider.
|
|
||||||
freg := flow.NewRegistry()
|
freg := flow.NewRegistry()
|
||||||
flow.RegisterBuiltinHooks(freg)
|
flow.RegisterBuiltinHooks(freg)
|
||||||
order.RegisterFlowActions(freg, s.applier, provider)
|
order.RegisterFlowActions(freg, s.applier, provider)
|
||||||
order.RegisterEmitHook(freg, nil)
|
order.RegisterEmitHook(freg, nil)
|
||||||
|
|
||||||
engine := flow.NewEngine(freg, s.logger)
|
engine := flow.NewEngine(freg, s.logger)
|
||||||
|
|
||||||
st := flow.NewState(ordID, s.logger)
|
st := flow.NewState(ordID, s.logger)
|
||||||
st.Vars[order.PlaceOrderVar] = po
|
st.Vars[order.PlaceOrderVar] = po
|
||||||
|
|
||||||
res, runErr := engine.Run(r.Context(), def, st)
|
res, runErr := engine.Run(ctx, def, st)
|
||||||
grain, getErr := s.applier.Get(r.Context(), ordID)
|
grain, getErr := s.applier.Get(ctx, ordID)
|
||||||
if getErr != nil {
|
if getErr != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, getErr)
|
return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// On failure the flow should have compensated (voided + cancelled). Return
|
|
||||||
// the failed order with the flow trace so the checkout service can decide
|
|
||||||
// how to proceed (e.g. alert operator, retry with a new idempotency key).
|
|
||||||
status := http.StatusCreated
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
status = http.StatusPaymentRequired
|
|
||||||
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
|
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the idempotency key durably only on success, so retries (incl. after
|
|
||||||
// a restart) return the captured order. A failed flow (402) is deliberately
|
|
||||||
// NOT recorded: the failed grain is terminal, and a retry with the same key
|
|
||||||
// should re-attempt rather than be handed back a dead order as a 409 hit
|
|
||||||
// (which the checkout client treats as a successful, existing order).
|
|
||||||
if req.IdempotencyKey != "" && runErr == nil {
|
if req.IdempotencyKey != "" && runErr == nil {
|
||||||
if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil {
|
if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil {
|
||||||
s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err)
|
s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, status, map[string]any{
|
return ordID, res, grain, false, runErr, nil
|
||||||
"orderId": id.String(),
|
|
||||||
"flow": res,
|
|
||||||
"order": grain,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
|
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
|
||||||
@@ -169,11 +164,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
|
|||||||
var total, totalTax int64
|
var total, totalTax int64
|
||||||
for _, l := range req.Lines {
|
for _, l := range req.Lines {
|
||||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
lineTotal := l.UnitPrice * int64(l.Quantity)
|
||||||
var lineTax int64
|
lineTax := order.ComputeTax(lineTotal, int(l.TaxRate))
|
||||||
if l.TaxRate > 0 {
|
|
||||||
// inc-vat -> tax portion = total * rate / (100 + rate)
|
|
||||||
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
|
|
||||||
}
|
|
||||||
total += lineTotal
|
total += lineTotal
|
||||||
totalTax += lineTax
|
totalTax += lineTax
|
||||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||||
|
|||||||
+30
-11
@@ -41,6 +41,7 @@ type server struct {
|
|||||||
freg *flow.Registry
|
freg *flow.Registry
|
||||||
flows *flowStore
|
flows *flowStore
|
||||||
provider order.PaymentProvider
|
provider order.PaymentProvider
|
||||||
|
taxProvider order.TaxProvider
|
||||||
defaultFlow string
|
defaultFlow string
|
||||||
dataDir string
|
dataDir string
|
||||||
idem *idempotency.Store
|
idem *idempotency.Store
|
||||||
@@ -134,9 +135,12 @@ func main() {
|
|||||||
log.Fatalf("open idempotency store: %v", err)
|
log.Fatalf("open idempotency store: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
taxProvider := selectTaxProvider()
|
||||||
|
|
||||||
s := &server{
|
s := &server{
|
||||||
pool: pool, applier: applier, storage: storage, reg: reg,
|
pool: pool, applier: applier, storage: storage, reg: reg,
|
||||||
engine: engine, freg: freg, flows: flows, provider: provider,
|
engine: engine, freg: freg, flows: flows, provider: provider,
|
||||||
|
taxProvider: taxProvider,
|
||||||
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
|
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,12 +154,14 @@ func main() {
|
|||||||
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
|
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
|
||||||
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
|
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}/fulfillments", s.handleFulfill)
|
||||||
mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete)
|
mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete)
|
||||||
mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel)
|
mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel)
|
||||||
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
|
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
|
||||||
mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
|
mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
|
||||||
|
mux.HandleFunc("POST /api/orders/{id}/exchanges", s.handleExchange)
|
||||||
|
mux.HandleFunc("POST /api/orders/{id}/edit", s.handleEditDetails)
|
||||||
|
|
||||||
// UCP REST adapter — Universal Commerce Protocol order lifecycle.
|
// UCP REST adapter — Universal Commerce Protocol order lifecycle.
|
||||||
orderUCP := ucp.OrderHandler(s.applier)
|
orderUCP := ucp.OrderHandler(s.applier)
|
||||||
@@ -183,10 +189,9 @@ func main() {
|
|||||||
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
|
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
|
||||||
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
|
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
|
||||||
|
|
||||||
// Ingest legacy checkout orders (Klarna/Adyen → order-queue) so this service
|
// Ingest checkout orders from order-queue (Klarna/Adyen fallback).
|
||||||
// is the single source of orders, superseding go-order-manager.
|
|
||||||
if amqpURL != "" {
|
if amqpURL != "" {
|
||||||
startLegacyIngest(context.Background(), amqpURL, applier, logger)
|
startOrderIngest(context.Background(), amqpURL, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
|
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
|
||||||
@@ -292,7 +297,7 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
po := buildPlaceOrder(id, &req)
|
po := s.buildPlaceOrder(id, &req)
|
||||||
|
|
||||||
st := flow.NewState(uint64(id), s.logger)
|
st := flow.NewState(uint64(id), s.logger)
|
||||||
st.Vars[order.PlaceOrderVar] = po
|
st.Vars[order.PlaceOrderVar] = po
|
||||||
@@ -320,7 +325,8 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// buildPlaceOrder converts a checkout request into the PlaceOrder event,
|
// buildPlaceOrder converts a checkout request into the PlaceOrder event,
|
||||||
// computing per-line and order totals (inc-vat) from the lines.
|
// computing per-line and order totals (inc-vat) from the lines.
|
||||||
func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
// Tax amounts are computed using the configured TaxProvider.
|
||||||
|
func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||||
po := &messages.PlaceOrder{
|
po := &messages.PlaceOrder{
|
||||||
OrderReference: req.OrderReference,
|
OrderReference: req.OrderReference,
|
||||||
CartId: req.CartId,
|
CartId: req.CartId,
|
||||||
@@ -336,11 +342,7 @@ func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
|||||||
var total, totalTax int64
|
var total, totalTax int64
|
||||||
for _, l := range req.Lines {
|
for _, l := range req.Lines {
|
||||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
lineTotal := l.UnitPrice * int64(l.Quantity)
|
||||||
var lineTax int64
|
lineTax := s.taxProvider.ComputeTax(lineTotal, int(l.TaxRate))
|
||||||
if l.TaxRate > 0 {
|
|
||||||
// inc-vat -> tax portion = total * rate / (100 + rate)
|
|
||||||
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
|
|
||||||
}
|
|
||||||
total += lineTotal
|
total += lineTotal
|
||||||
totalTax += lineTax
|
totalTax += lineTax
|
||||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||||
@@ -450,10 +452,27 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// --- helpers --------------------------------------------------------------
|
// --- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
// selectTaxProvider picks the tax provider from the environment. Default is
|
||||||
|
// NordicTaxProvider with SE as the default country (matching the current
|
||||||
|
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
|
||||||
|
// (no country awareness).
|
||||||
|
func selectTaxProvider() order.TaxProvider {
|
||||||
|
provider := os.Getenv("TAX_PROVIDER")
|
||||||
|
switch provider {
|
||||||
|
case "static":
|
||||||
|
return order.NewStaticTaxProvider()
|
||||||
|
case "nordic":
|
||||||
|
return order.NewNordicTaxProvider(envOr("TAX_DEFAULT_COUNTRY", "SE"))
|
||||||
|
default:
|
||||||
|
return order.NewNordicTaxProvider(envOr("TAX_DEFAULT_COUNTRY", "SE"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// selectProvider picks the payment provider from the environment. Default is
|
// selectProvider picks the payment provider from the environment. Default is
|
||||||
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
|
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
|
||||||
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
|
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
|
||||||
func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
||||||
|
|
||||||
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
|
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
|
||||||
key := os.Getenv("STRIPE_SECRET_KEY")
|
key := os.Getenv("STRIPE_SECRET_KEY")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
|
|||||||
@@ -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/internal/ucp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// buildAuthStorage selects the credential store and login limiter that back the
|
||||||
|
// customer-auth surface. With REDIS_ADDRESS set it uses shared Redis storage so
|
||||||
|
// the service scales horizontally; otherwise it falls back to the file-backed
|
||||||
|
// store and an in-memory limiter (single-instance / local dev). A Redis failure
|
||||||
|
// at startup is fatal rather than silently degrading to per-replica state.
|
||||||
|
func buildAuthStorage(ctx context.Context, profileDir string) (customerauth.Credentials, customerauth.Limiter) {
|
||||||
|
if addr := os.Getenv("REDIS_ADDRESS"); addr != "" {
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Password: os.Getenv("REDIS_PASSWORD"),
|
||||||
|
DB: 0,
|
||||||
|
})
|
||||||
|
store, err := customerauth.NewRedisCredentialStore(ctx, rdb)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error connecting customer-auth credential store to Redis at %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
log.Printf("customer-auth: using shared Redis storage at %s", addr)
|
||||||
|
return store, customerauth.NewRedisLoginLimiter(rdb, 0, 0)
|
||||||
|
}
|
||||||
|
credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error loading credential store: %v", err)
|
||||||
|
}
|
||||||
|
log.Print("customer-auth: REDIS_ADDRESS unset — using file credential store + in-memory limiter (single-instance only)")
|
||||||
|
return credStore, customerauth.NewLoginLimiter(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// authSecret returns the HMAC key for customer session cookies. It reads
|
// authSecret returns the HMAC key for customer session cookies. It reads
|
||||||
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
|
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
|
||||||
// warns that issued sessions will not survive a restart (fine for dev).
|
// warns that issued sessions will not survive a restart (fine for dev).
|
||||||
@@ -86,6 +116,13 @@ func main() {
|
|||||||
// Destroy is an optional grain-eviction cleanup hook; profiles need none
|
// Destroy is an optional grain-eviction cleanup hook; profiles need none
|
||||||
// (disk storage persists via its own loop), so it's left unset — purge()
|
// (disk storage persists via its own loop), so it's left unset — purge()
|
||||||
// skips a nil Destroy.
|
// skips a nil Destroy.
|
||||||
|
// SpawnHost makes the pool cluster-aware: when a grain is owned by a peer
|
||||||
|
// replica, the pool proxies to it over gRPC :1337 (control) + HTTP :8080
|
||||||
|
// (requests). With POD_IP unset and no discovered peers this is never
|
||||||
|
// invoked, so the service still runs fine single-node.
|
||||||
|
SpawnHost: func(host string) (actor.Host[profile.ProfileGrain], error) {
|
||||||
|
return proxy.NewRemoteHost[profile.ProfileGrain](host)
|
||||||
|
},
|
||||||
TTL: 10 * time.Minute,
|
TTL: 10 * time.Minute,
|
||||||
PoolSize: 65535,
|
PoolSize: 65535,
|
||||||
Hostname: podIp,
|
Hostname: podIp,
|
||||||
@@ -96,19 +133,40 @@ func main() {
|
|||||||
log.Fatalf("Error creating profile pool: %v\n", err)
|
log.Fatalf("Error creating profile pool: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clustering control plane: gRPC server on :1337 serves peer pools'
|
||||||
|
// ownership announcements and remote Apply/Get calls; UseDiscovery watches
|
||||||
|
// for sibling profile pods (label actor-pool=profile) and wires them into the
|
||||||
|
// pool. Both are no-ops in single-node mode (POD_IP unset → nil discovery).
|
||||||
|
grpcSrv, err := actor.NewControlServer[profile.ProfileGrain](actor.DefaultServerConfig(), pool)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||||
|
}
|
||||||
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
|
UseDiscovery(pool)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
debugMux := http.NewServeMux()
|
debugMux := http.NewServeMux()
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
otelShutdown, err := setupOTelSDK(ctx)
|
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customer auth storage and failed-login limiter.
|
||||||
|
// Credentials and the failed-login limiter use shared Redis storage when
|
||||||
|
// REDIS_ADDRESS is set (so the service scales horizontally), and fall back to
|
||||||
|
// the file store + in-memory limiter for single-instance / local dev. Session
|
||||||
|
// and verify/reset tokens are stateless HMAC values, so a shared
|
||||||
|
// CUSTOMER_AUTH_SECRET is all they need to work across replicas.
|
||||||
|
credStore, limiter := buildAuthStorage(ctx, profileDir)
|
||||||
|
|
||||||
// UCP Customer REST adapter
|
// UCP Customer REST adapter
|
||||||
customerUCP := ucp.CustomerHandler(pool)
|
auditLogPath := filepath.Join(profileDir, "audit.log")
|
||||||
|
customerUCP := ucp.CustomerHandler(pool, auditLogPath, credStore)
|
||||||
if signer := loadUCPProfileSigner(); signer != nil {
|
if signer := loadUCPProfileSigner(); signer != nil {
|
||||||
customerUCP = ucp.WithSigning(customerUCP, signer)
|
customerUCP = ucp.WithSigning(customerUCP, signer)
|
||||||
log.Print("ucp customer signing enabled")
|
log.Print("ucp customer signing enabled")
|
||||||
@@ -119,11 +177,11 @@ func main() {
|
|||||||
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
|
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
|
||||||
|
|
||||||
// Customer auth: password signup/login + session cookies + identity linking.
|
// Customer auth: password signup/login + session cookies + identity linking.
|
||||||
credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json"))
|
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{
|
||||||
if err != nil {
|
Limiter: limiter,
|
||||||
log.Fatalf("Error loading credential store: %v\n", err)
|
BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
|
||||||
}
|
RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true",
|
||||||
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0).Handler()
|
}).Handler()
|
||||||
mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler))
|
mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler))
|
||||||
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package customerauth
|
package customerauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -73,11 +74,12 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load: %v", err)
|
t.Fatalf("load: %v", err)
|
||||||
}
|
}
|
||||||
if err := store.Register("User@Example.com", 42, "hash1", "ts"); err != nil {
|
ctx := context.Background()
|
||||||
|
if err := store.Register(ctx, "User@Example.com", 42, "hash1", "ts"); err != nil {
|
||||||
t.Fatalf("register: %v", err)
|
t.Fatalf("register: %v", err)
|
||||||
}
|
}
|
||||||
// Duplicate (case-insensitive) is rejected.
|
// Duplicate (case-insensitive) is rejected.
|
||||||
if err := store.Register("user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
|
if err := store.Register(ctx, "user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
|
||||||
t.Fatalf("duplicate: got %v, want ErrEmailExists", err)
|
t.Fatalf("duplicate: got %v, want ErrEmailExists", err)
|
||||||
}
|
}
|
||||||
// Reload from disk and confirm the record persisted.
|
// Reload from disk and confirm the record persisted.
|
||||||
@@ -85,7 +87,7 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reload: %v", err)
|
t.Fatalf("reload: %v", err)
|
||||||
}
|
}
|
||||||
rec, ok := reloaded.Get("USER@EXAMPLE.COM")
|
rec, ok := reloaded.Get(ctx, "USER@EXAMPLE.COM")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("record not found after reload")
|
t.Fatal("record not found after reload")
|
||||||
}
|
}
|
||||||
@@ -93,3 +95,39 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
|||||||
t.Fatalf("unexpected record: %+v", rec)
|
t.Fatalf("unexpected record: %+v", rec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStoreDelete(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "credentials.json")
|
||||||
|
store, err := LoadCredentialStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.Register(ctx, "user@example.com", 42, "hash1", "ts"); err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
ok, err := store.Delete(ctx, "USER@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete failed: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected delete to report email was found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm not found
|
||||||
|
if _, ok := store.Get(ctx, "user@example.com"); ok {
|
||||||
|
t.Fatal("record still exists after delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload from disk and verify it's gone
|
||||||
|
reloaded, err := LoadCredentialStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := reloaded.Get(ctx, "user@example.com"); ok {
|
||||||
|
t.Fatal("record still exists after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,9 +17,24 @@ import (
|
|||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// minPasswordLen is the minimum accepted password length at registration.
|
// minPasswordLen is the minimum accepted password length at registration and
|
||||||
|
// password reset.
|
||||||
const minPasswordLen = 8
|
const minPasswordLen = 8
|
||||||
|
|
||||||
|
// Token lifetimes for the email-verification and password-reset links.
|
||||||
|
const (
|
||||||
|
verifyTokenTTL = 24 * time.Hour
|
||||||
|
resetTokenTTL = 1 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// Storefront paths (appended to Options.BaseURL) that the verification and reset
|
||||||
|
// links point at. The page reads the token from the query string and POSTs it
|
||||||
|
// back to /verify or /reset.
|
||||||
|
const (
|
||||||
|
verifyLinkPath = "/account/verify"
|
||||||
|
resetLinkPath = "/account/reset"
|
||||||
|
)
|
||||||
|
|
||||||
// ProfileApplier is the subset of the profile grain pool the auth server needs.
|
// ProfileApplier is the subset of the profile grain pool the auth server needs.
|
||||||
// The pool (and the UCP adapter's ProfileApplier) already satisfies it.
|
// The pool (and the UCP adapter's ProfileApplier) already satisfies it.
|
||||||
type ProfileApplier interface {
|
type ProfileApplier interface {
|
||||||
@@ -24,21 +42,60 @@ type ProfileApplier interface {
|
|||||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server exposes password signup/login and identity-linking over HTTP, backed
|
// Server exposes password signup/login, email verification, password reset and
|
||||||
// by the credential store (email→id+hash) and the profile grain pool.
|
// identity-linking over HTTP, backed by the credential store (email→id+hash) and
|
||||||
|
// the profile grain pool.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
store *CredentialStore
|
store Credentials
|
||||||
applier ProfileApplier
|
applier ProfileApplier
|
||||||
signer *Signer
|
signer *Signer
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
|
limiter Limiter
|
||||||
|
notifier Notifier
|
||||||
|
baseURL string
|
||||||
|
requireVerified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options configures optional auth-server behavior. The zero value is valid: an
|
||||||
|
// in-memory login limiter (5 failures / 15m), a logging notifier, and no
|
||||||
|
// verified-email requirement for login.
|
||||||
|
type Options struct {
|
||||||
|
// Limiter throttles failed logins and reset requests. Nil installs a default
|
||||||
|
// in-memory limiter (5 failures / 15m). Inject a RedisLoginLimiter for a
|
||||||
|
// horizontally-scaled deployment.
|
||||||
|
Limiter Limiter
|
||||||
|
// Notifier delivers verification and reset links. Nil installs LogNotifier.
|
||||||
|
Notifier Notifier
|
||||||
|
// BaseURL is the public origin used to build links in messages, e.g.
|
||||||
|
// "https://shop.tornberg.me". The verify/reset paths are appended to it.
|
||||||
|
BaseURL string
|
||||||
|
// RequireVerifiedEmail, when true, blocks login until the email is verified.
|
||||||
|
// Off by default so pre-existing accounts (which carry no verification
|
||||||
|
// record) keep working.
|
||||||
|
RequireVerifiedEmail bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL.
|
// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL.
|
||||||
func NewServer(store *CredentialStore, applier ProfileApplier, signer *Signer, ttl time.Duration) *Server {
|
func NewServer(store Credentials, applier ProfileApplier, signer *Signer, ttl time.Duration, opts Options) *Server {
|
||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = DefaultSessionTTL
|
ttl = DefaultSessionTTL
|
||||||
}
|
}
|
||||||
return &Server{store: store, applier: applier, signer: signer, ttl: ttl}
|
if opts.Limiter == nil {
|
||||||
|
opts.Limiter = NewLoginLimiter(0, 0)
|
||||||
|
}
|
||||||
|
if opts.Notifier == nil {
|
||||||
|
opts.Notifier = LogNotifier{}
|
||||||
|
}
|
||||||
|
return &Server{
|
||||||
|
store: store,
|
||||||
|
applier: applier,
|
||||||
|
signer: signer,
|
||||||
|
ttl: ttl,
|
||||||
|
limiter: opts.Limiter,
|
||||||
|
notifier: opts.Notifier,
|
||||||
|
baseURL: strings.TrimRight(opts.BaseURL, "/"),
|
||||||
|
requireVerified: opts.RequireVerifiedEmail,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth
|
// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth
|
||||||
@@ -49,6 +106,10 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /login", s.handleLogin)
|
mux.HandleFunc("POST /login", s.handleLogin)
|
||||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
mux.HandleFunc("GET /me", s.handleMe)
|
mux.HandleFunc("GET /me", s.handleMe)
|
||||||
|
mux.HandleFunc("POST /verify-request", s.handleVerifyRequest)
|
||||||
|
mux.HandleFunc("POST /verify", s.handleVerify)
|
||||||
|
mux.HandleFunc("POST /reset-request", s.handleResetRequest)
|
||||||
|
mux.HandleFunc("POST /reset", s.handleResetComplete)
|
||||||
mux.HandleFunc("POST /link-cart", s.handleLinkCart)
|
mux.HandleFunc("POST /link-cart", s.handleLinkCart)
|
||||||
mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout)
|
mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout)
|
||||||
mux.HandleFunc("POST /link-order", s.handleLinkOrder)
|
mux.HandleFunc("POST /link-order", s.handleLinkOrder)
|
||||||
@@ -86,6 +147,22 @@ type linkOrderRequest struct {
|
|||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokenRequest carries a verification token.
|
||||||
|
type tokenRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetRequest asks for a password-reset link to be sent to an email.
|
||||||
|
type resetRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetCompleteRequest carries a reset token and the new password.
|
||||||
|
type resetCompleteRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
|
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
|
||||||
// import cycle with internal/ucp). The password hash is never included.
|
// import cycle with internal/ucp). The password hash is never included.
|
||||||
type CustomerResponse struct {
|
type CustomerResponse struct {
|
||||||
@@ -98,6 +175,9 @@ type CustomerResponse struct {
|
|||||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
Addresses []AddressResponse `json:"addresses"`
|
Addresses []AddressResponse `json:"addresses"`
|
||||||
Orders []OrderRef `json:"orders"`
|
Orders []OrderRef `json:"orders"`
|
||||||
|
|
||||||
|
// EmailVerified reflects whether the email-verification flow has completed.
|
||||||
|
EmailVerified bool `json:"emailVerified"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddressResponse struct {
|
type AddressResponse struct {
|
||||||
@@ -137,7 +217,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, exists := s.store.Get(email); exists {
|
if _, exists := s.store.Get(r.Context(), email); exists {
|
||||||
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -169,7 +249,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Persist the credential only after the profile is created. If this fails
|
// Persist the credential only after the profile is created. If this fails
|
||||||
// the profile exists but is unreachable by login — acceptable and rare.
|
// the profile exists but is unreachable by login — acceptable and rare.
|
||||||
if err := s.store.Register(email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
if err := s.store.Register(r.Context(), email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
if err == ErrEmailExists {
|
if err == ErrEmailExists {
|
||||||
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
||||||
return
|
return
|
||||||
@@ -178,6 +258,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send the verification link (best-effort, via the configured notifier).
|
||||||
|
s.sendVerification(email)
|
||||||
|
|
||||||
s.issueSession(w, r, id)
|
s.issueSession(w, r, id)
|
||||||
s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id)
|
s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id)
|
||||||
}
|
}
|
||||||
@@ -188,14 +271,26 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec, ok := s.store.Get(req.Email)
|
key := NormalizeEmail(req.Email)
|
||||||
|
if ok, retry := s.limiter.Allowed(r.Context(), key); !ok {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
|
||||||
|
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, ok := s.store.Get(r.Context(), req.Email)
|
||||||
// Always run a verify (even on miss, against the stored hash if present) and
|
// Always run a verify (even on miss, against the stored hash if present) and
|
||||||
// return a single generic error so the response does not reveal whether the
|
// return a single generic error so the response does not reveal whether the
|
||||||
// email exists.
|
// email exists.
|
||||||
if !ok || !VerifyPassword(req.Password, rec.Hash) {
|
if !ok || !VerifyPassword(req.Password, rec.Hash) {
|
||||||
|
s.limiter.RecordFailure(r.Context(), key)
|
||||||
writeErr(w, http.StatusUnauthorized, "invalid email or password")
|
writeErr(w, http.StatusUnauthorized, "invalid email or password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if s.requireVerified && rec.VerifiedAt == "" {
|
||||||
|
writeErr(w, http.StatusForbidden, "email not verified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.limiter.Reset(r.Context(), key)
|
||||||
s.issueSession(w, r, rec.ProfileID)
|
s.issueSession(w, r, rec.ProfileID)
|
||||||
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID)
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID)
|
||||||
}
|
}
|
||||||
@@ -213,6 +308,101 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleVerifyRequest re-sends the email-verification link for the logged-in
|
||||||
|
// customer. Requires a session so it can't be used to enumerate addresses.
|
||||||
|
func (s *Server) handleVerifyRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := s.requireSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g, err := s.applier.Get(r.Context(), id)
|
||||||
|
if err != nil || g.Email == "" {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendVerification(NormalizeEmail(g.Email))
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVerify consumes a verification token and marks the email verified. An
|
||||||
|
// unknown subject still returns success so the endpoint reveals nothing.
|
||||||
|
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req tokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email, err := s.signer.ParsePurpose(purposeVerifyEmail, req.Token)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.MarkVerified(r.Context(), email, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not record verification")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetRequest issues a password-reset link for a registered email. It
|
||||||
|
// always returns 200 (whether or not the email exists) so it never reveals
|
||||||
|
// account existence, and is rate-limited per email to prevent mail-bombing.
|
||||||
|
func (s *Server) handleResetRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req resetRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := NormalizeEmail(req.Email)
|
||||||
|
limiterKey := "reset:" + email
|
||||||
|
if ok, retry := s.limiter.Allowed(r.Context(), limiterKey); !ok {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
|
||||||
|
writeErr(w, http.StatusTooManyRequests, "too many requests, try again later")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := s.store.Get(r.Context(), email); exists {
|
||||||
|
token := s.signer.IssuePurpose(purposePasswordReset, email, resetTokenTTL)
|
||||||
|
s.notifier.SendPasswordReset(email, s.link(resetLinkPath, token))
|
||||||
|
}
|
||||||
|
s.limiter.RecordFailure(r.Context(), limiterKey)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetComplete consumes a reset token and replaces the password hash.
|
||||||
|
func (s *Server) handleResetComplete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req resetCompleteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Password) < minPasswordLen {
|
||||||
|
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email, err := s.signer.ParsePurpose(purposePasswordReset, req.Token)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found, err := s.store.UpdateHash(r.Context(), email, hash)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not update password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
// Token signed for an email no longer present.
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.limiter.Reset(r.Context(), NormalizeEmail(email))
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := s.requireSession(w, r)
|
id, ok := s.requireSession(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -292,6 +482,19 @@ func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, id uint64)
|
|||||||
SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl)
|
SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendVerification mints a verification token for email and hands the link to
|
||||||
|
// the notifier. email must already be normalized.
|
||||||
|
func (s *Server) sendVerification(email string) {
|
||||||
|
token := s.signer.IssuePurpose(purposeVerifyEmail, email, verifyTokenTTL)
|
||||||
|
s.notifier.SendEmailVerification(email, s.link(verifyLinkPath, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// link builds an absolute link to a storefront path carrying the token as a
|
||||||
|
// query parameter. With no configured BaseURL it returns a relative link.
|
||||||
|
func (s *Server) link(path, token string) string {
|
||||||
|
return fmt.Sprintf("%s%s?token=%s", s.baseURL, path, url.QueryEscape(token))
|
||||||
|
}
|
||||||
|
|
||||||
// requireSession reads and validates the session cookie, writing 401 on
|
// requireSession reads and validates the session cookie, writing 401 on
|
||||||
// failure. It returns the profile id and whether a valid session was present.
|
// failure. It returns the profile id and whether a valid session was present.
|
||||||
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
||||||
@@ -315,7 +518,11 @@ func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status in
|
|||||||
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, status, grainToCustomer(idStr, g))
|
resp := grainToCustomer(idStr, g)
|
||||||
|
if rec, ok := s.store.Get(r.Context(), g.Email); ok {
|
||||||
|
resp.EmailVerified = rec.VerifiedAt != ""
|
||||||
|
}
|
||||||
|
writeJSON(w, status, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
|
func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package customerauth
|
package customerauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,14 +15,36 @@ import (
|
|||||||
// associated with a credential record.
|
// associated with a credential record.
|
||||||
var ErrEmailExists = errors.New("customerauth: email already registered")
|
var ErrEmailExists = errors.New("customerauth: email already registered")
|
||||||
|
|
||||||
|
// Credentials is the email→credential index the auth server depends on. It is
|
||||||
|
// satisfied by the file-backed CredentialStore (single-instance / dev) and by
|
||||||
|
// RedisCredentialStore (shared, horizontally scalable). All methods normalize
|
||||||
|
// the email key internally.
|
||||||
|
type Credentials interface {
|
||||||
|
// Get returns the record for email and whether it exists. A backing-store
|
||||||
|
// failure is reported as "not found" so callers fail closed.
|
||||||
|
Get(ctx context.Context, email string) (Record, bool)
|
||||||
|
// Register adds a new credential, returning ErrEmailExists if taken.
|
||||||
|
Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error
|
||||||
|
// MarkVerified records email verification. The bool reports whether the
|
||||||
|
// email was known; an unknown email is not an error (avoids enumeration).
|
||||||
|
MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error)
|
||||||
|
// UpdateHash replaces the password hash. The bool reports whether the email
|
||||||
|
// was known; an unknown email is not an error.
|
||||||
|
UpdateHash(ctx context.Context, email, hash string) (bool, error)
|
||||||
|
// Delete removes a credential record by email. The bool reports whether the email
|
||||||
|
// was known.
|
||||||
|
Delete(ctx context.Context, email string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Record is a single stored credential: which profile an email belongs to and
|
// Record is a single stored credential: which profile an email belongs to and
|
||||||
// its password hash. It deliberately does not embed any profile data — the
|
// its password hash. It deliberately does not embed any profile data — the
|
||||||
// profile grain remains the source of truth for that.
|
// profile grain remains the source of truth for that.
|
||||||
type Record struct {
|
type Record struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
ProfileID uint64 `json:"profileId"`
|
ProfileID uint64 `json:"profileId"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
CreatedAt string `json:"createdAt,omitempty"`
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
|
VerifiedAt string `json:"verifiedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CredentialStore is a small email→credential index persisted to a JSON file.
|
// CredentialStore is a small email→credential index persisted to a JSON file.
|
||||||
@@ -61,8 +84,9 @@ func NormalizeEmail(email string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(email))
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the record for email and whether it exists.
|
// Get returns the record for email and whether it exists. The ctx is accepted
|
||||||
func (s *CredentialStore) Get(email string) (Record, bool) {
|
// for interface parity with the Redis store; the file store ignores it.
|
||||||
|
func (s *CredentialStore) Get(_ context.Context, email string) (Record, bool) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
r, ok := s.byMail[NormalizeEmail(email)]
|
r, ok := s.byMail[NormalizeEmail(email)]
|
||||||
@@ -71,7 +95,7 @@ func (s *CredentialStore) Get(email string) (Record, bool) {
|
|||||||
|
|
||||||
// Register adds a new credential record and persists the store. It returns
|
// Register adds a new credential record and persists the store. It returns
|
||||||
// ErrEmailExists if the email is already taken.
|
// ErrEmailExists if the email is already taken.
|
||||||
func (s *CredentialStore) Register(email string, profileID uint64, hash, createdAt string) error {
|
func (s *CredentialStore) Register(_ context.Context, email string, profileID uint64, hash, createdAt string) error {
|
||||||
key := NormalizeEmail(email)
|
key := NormalizeEmail(email)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -82,6 +106,54 @@ func (s *CredentialStore) Register(email string, profileID uint64, hash, created
|
|||||||
return s.persistLocked()
|
return s.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkVerified records that email completed email verification and persists the
|
||||||
|
// store. It is a no-op if already verified. The bool reports whether the email
|
||||||
|
// was known; an unknown email is not an error (callers avoid leaking which
|
||||||
|
// emails exist).
|
||||||
|
func (s *CredentialStore) MarkVerified(_ context.Context, email, verifiedAt string) (bool, error) {
|
||||||
|
key := NormalizeEmail(email)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
rec, ok := s.byMail[key]
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if rec.VerifiedAt != "" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
rec.VerifiedAt = verifiedAt
|
||||||
|
s.byMail[key] = rec
|
||||||
|
return true, s.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateHash replaces the stored password hash for email and persists the store.
|
||||||
|
// The bool reports whether the email was known; an unknown email is not an error.
|
||||||
|
func (s *CredentialStore) UpdateHash(_ context.Context, email, hash string) (bool, error) {
|
||||||
|
key := NormalizeEmail(email)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
rec, ok := s.byMail[key]
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
rec.Hash = hash
|
||||||
|
s.byMail[key] = rec
|
||||||
|
return true, s.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the credential record for email and persists the store.
|
||||||
|
// The bool reports whether the email was known.
|
||||||
|
func (s *CredentialStore) Delete(_ context.Context, email string) (bool, error) {
|
||||||
|
key := NormalizeEmail(email)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.byMail[key]; !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
delete(s.byMail, key)
|
||||||
|
return true, s.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
// persistLocked writes the whole store to disk atomically. Caller holds s.mu.
|
// persistLocked writes the whole store to disk atomically. Caller holds s.mu.
|
||||||
func (s *CredentialStore) persistLocked() error {
|
func (s *CredentialStore) persistLocked() error {
|
||||||
records := make([]Record, 0, len(s.byMail))
|
records := make([]Record, 0, len(s.byMail))
|
||||||
|
|||||||
@@ -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,
|
Name: name,
|
||||||
Quantity: int32(it.Quantity),
|
Quantity: int32(it.Quantity),
|
||||||
UnitPrice: it.Price.IncVat,
|
UnitPrice: it.Price.IncVat,
|
||||||
TaxRate: int32(it.Tax),
|
// CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
|
||||||
|
TaxRate: int32(it.Tax / 100),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, d := range g.Deliveries {
|
for _, d := range g.Deliveries {
|
||||||
@@ -567,7 +568,7 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
|||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: d.Price.IncVat,
|
UnitPrice: d.Price.IncVat,
|
||||||
TaxRate: 2500,
|
TaxRate: 25,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
@@ -15,12 +16,18 @@ import (
|
|||||||
|
|
||||||
// CustomerServer is the UCP REST adapter over the profile grain pool.
|
// CustomerServer is the UCP REST adapter over the profile grain pool.
|
||||||
type CustomerServer struct {
|
type CustomerServer struct {
|
||||||
applier ProfileApplier
|
applier ProfileApplier
|
||||||
|
deleter CredentialDeleter
|
||||||
|
auditLogPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
|
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
|
||||||
func NewCustomerServer(applier ProfileApplier) *CustomerServer {
|
func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) *CustomerServer {
|
||||||
return &CustomerServer{applier: applier}
|
var del CredentialDeleter
|
||||||
|
if len(deleter) > 0 {
|
||||||
|
del = deleter[0]
|
||||||
|
}
|
||||||
|
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -276,6 +283,59 @@ func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Requ
|
|||||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteCustomer handles DELETE /customers/{id}.
|
||||||
|
func (s *CustomerServer) handleDeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseCustomerID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fetch current profile grain to get the email address.
|
||||||
|
g, err := s.applier.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "customer not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := g.Email
|
||||||
|
|
||||||
|
// 2. Apply the AnonymizeProfile mutation to clear all PII.
|
||||||
|
msg := &messages.AnonymizeProfile{}
|
||||||
|
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to anonymize customer: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete the credentials from the auth store if we have a deleter.
|
||||||
|
if email != "" && s.deleter != nil {
|
||||||
|
if _, err := s.deleter.Delete(r.Context(), email); err != nil {
|
||||||
|
fmt.Printf("ucp: failed to delete credentials for email %s: %v\n", email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Log audit trail to file (GDPR requirement, append-only, readable, no PII)
|
||||||
|
if s.auditLogPath != "" {
|
||||||
|
logLine := fmt.Sprintf("[%s] ACTION=GDPR_ERASURE PROFILE_ID=%s STATUS=SUCCESS\n",
|
||||||
|
time.Now().UTC().Format(time.RFC3339), r.PathValue("id"))
|
||||||
|
|
||||||
|
// Import "os" package is handled or we can open it directly.
|
||||||
|
// Since we need to write to the file, let's open it in append-only mode.
|
||||||
|
// To ensure directory exists, we write to the file.
|
||||||
|
if f, err := os.OpenFile(s.auditLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||||
|
_, _ = f.WriteString(logLine)
|
||||||
|
_ = f.Close()
|
||||||
|
} else {
|
||||||
|
fmt.Printf("ucp: failed to open audit log file %s: %v\n", s.auditLogPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit trail to stdout (GDPR requirement)
|
||||||
|
fmt.Printf("AUDIT: GDPR right to erasure executed for customer profile id %d\n", id)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Identity linking helpers
|
// Identity linking helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -105,6 +107,12 @@ func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto
|
|||||||
g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId})
|
g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId})
|
||||||
case *messages.LinkOrder:
|
case *messages.LinkOrder:
|
||||||
g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status})
|
g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status})
|
||||||
|
case *messages.AnonymizeProfile:
|
||||||
|
g.Name = ""
|
||||||
|
g.Email = ""
|
||||||
|
g.Phone = ""
|
||||||
|
g.AvatarUrl = ""
|
||||||
|
g.Addresses = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &actor.MutationResult[profile.ProfileGrain]{
|
return &actor.MutationResult[profile.ProfileGrain]{
|
||||||
@@ -138,7 +146,7 @@ func testProfileID(t *testing.T, applier *testProfileApplier) string {
|
|||||||
func TestGetCustomer(t *testing.T) {
|
func TestGetCustomer(t *testing.T) {
|
||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
id := testProfileID(t, applier)
|
id := testProfileID(t, applier)
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
|
|
||||||
// Set up some profile data
|
// Set up some profile data
|
||||||
pid, _ := profile.ParseProfileId(id)
|
pid, _ := profile.ParseProfileId(id)
|
||||||
@@ -173,7 +181,7 @@ func TestGetCustomer(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetCustomerNotFound(t *testing.T) {
|
func TestGetCustomerNotFound(t *testing.T) {
|
||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
|
|
||||||
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
|
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
|
||||||
req := httptest.NewRequest("GET", "/!invalid!", nil)
|
req := httptest.NewRequest("GET", "/!invalid!", nil)
|
||||||
@@ -188,7 +196,7 @@ func TestGetCustomerNotFound(t *testing.T) {
|
|||||||
func TestUpdateCustomer(t *testing.T) {
|
func TestUpdateCustomer(t *testing.T) {
|
||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
id := testProfileID(t, applier)
|
id := testProfileID(t, applier)
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
|
|
||||||
body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}`
|
body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}`
|
||||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body))
|
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body))
|
||||||
@@ -224,7 +232,7 @@ func TestUpdateCustomer(t *testing.T) {
|
|||||||
func TestAddAddress(t *testing.T) {
|
func TestAddAddress(t *testing.T) {
|
||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
id := testProfileID(t, applier)
|
id := testProfileID(t, applier)
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
|
|
||||||
body := `{
|
body := `{
|
||||||
"label": "Home",
|
"label": "Home",
|
||||||
@@ -266,7 +274,7 @@ func TestAddAddress(t *testing.T) {
|
|||||||
func TestAddAddressMissingRequired(t *testing.T) {
|
func TestAddAddressMissingRequired(t *testing.T) {
|
||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
id := testProfileID(t, applier)
|
id := testProfileID(t, applier)
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
|
|
||||||
// Missing addressLine1
|
// Missing addressLine1
|
||||||
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
|
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
|
||||||
@@ -283,7 +291,7 @@ func TestAddAddressMissingRequired(t *testing.T) {
|
|||||||
func TestUpdateAddress(t *testing.T) {
|
func TestUpdateAddress(t *testing.T) {
|
||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
id := testProfileID(t, applier)
|
id := testProfileID(t, applier)
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
|
|
||||||
// First add an address
|
// First add an address
|
||||||
pid, _ := profile.ParseProfileId(id)
|
pid, _ := profile.ParseProfileId(id)
|
||||||
@@ -337,7 +345,7 @@ func TestUpdateAddress(t *testing.T) {
|
|||||||
func TestUpdateAddressInvalidID(t *testing.T) {
|
func TestUpdateAddressInvalidID(t *testing.T) {
|
||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
id := testProfileID(t, applier)
|
id := testProfileID(t, applier)
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
|
|
||||||
body := `{"label": "New Home"}`
|
body := `{"label": "New Home"}`
|
||||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
|
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
|
||||||
@@ -353,7 +361,7 @@ func TestUpdateAddressInvalidID(t *testing.T) {
|
|||||||
func TestRemoveAddress(t *testing.T) {
|
func TestRemoveAddress(t *testing.T) {
|
||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
id := testProfileID(t, applier)
|
id := testProfileID(t, applier)
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
|
|
||||||
// First add an address
|
// First add an address
|
||||||
pid, _ := profile.ParseProfileId(id)
|
pid, _ := profile.ParseProfileId(id)
|
||||||
@@ -398,7 +406,7 @@ func TestRemoveAddressNotFound(t *testing.T) {
|
|||||||
applier.Get(context.Background(), uint64(pid))
|
applier.Get(context.Background(), uint64(pid))
|
||||||
|
|
||||||
id := pid.String()
|
id := pid.String()
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
|
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
@@ -444,7 +452,7 @@ func TestRemoveAddressNotFoundMutation(t *testing.T) {
|
|||||||
|
|
||||||
func TestCustomerHandlerInvalidID(t *testing.T) {
|
func TestCustomerHandlerInvalidID(t *testing.T) {
|
||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
handler := CustomerHandler(applier)
|
handler := CustomerHandler(applier, "")
|
||||||
|
|
||||||
// Path "/" now has POST / registered (create customer), so GET / is 405.
|
// Path "/" now has POST / registered (create customer), so GET / is 405.
|
||||||
// Paths with invalid base62 characters should get 400.
|
// Paths with invalid base62 characters should get 400.
|
||||||
@@ -496,3 +504,72 @@ func TestCustomerHandlerThroughCombinedMount(t *testing.T) {
|
|||||||
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testCredentialDeleter struct {
|
||||||
|
deletedEmail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *testCredentialDeleter) Delete(_ context.Context, email string) (bool, error) {
|
||||||
|
d.deletedEmail = email
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteCustomer(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
id := testProfileID(t, applier)
|
||||||
|
deleter := &testCredentialDeleter{}
|
||||||
|
auditPath := filepath.Join(t.TempDir(), "audit.log")
|
||||||
|
handler := CustomerHandler(applier, auditPath, deleter)
|
||||||
|
|
||||||
|
// Set up some profile data
|
||||||
|
pid, _ := profile.ParseProfileId(id)
|
||||||
|
applier.Apply(context.Background(), uint64(pid), &messages.SetProfile{
|
||||||
|
Name: proto.String("Alice"),
|
||||||
|
Email: proto.String("alice@example.com"),
|
||||||
|
})
|
||||||
|
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
||||||
|
Address: &messages.Address{
|
||||||
|
Label: "Home",
|
||||||
|
AddressLine1: "Storgatan 1",
|
||||||
|
City: "Stockholm",
|
||||||
|
Zip: "111 22",
|
||||||
|
Country: "SE",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /customers/{id}
|
||||||
|
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s", id), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204 No Content, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify credentials deleted
|
||||||
|
if deleter.deletedEmail != "alice@example.com" {
|
||||||
|
t.Fatalf("expected deleted email 'alice@example.com', got %q", deleter.deletedEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify profile is anonymized
|
||||||
|
g, _ := applier.Get(context.Background(), uint64(pid))
|
||||||
|
if g.Name != "" || g.Email != "" || g.Addresses != nil {
|
||||||
|
t.Fatalf("expected profile to be anonymized, got Name=%q, Email=%q, Addresses=%v", g.Name, g.Email, g.Addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify audit log has the correct entry and no PII
|
||||||
|
logBytes, err := os.ReadFile(auditPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read audit log: %v", err)
|
||||||
|
}
|
||||||
|
logContent := string(logBytes)
|
||||||
|
if !strings.Contains(logContent, "ACTION=GDPR_ERASURE") {
|
||||||
|
t.Fatal("expected audit log to record erasure action")
|
||||||
|
}
|
||||||
|
if !strings.Contains(logContent, "PROFILE_ID="+id) {
|
||||||
|
t.Fatal("expected audit log to record profile ID")
|
||||||
|
}
|
||||||
|
if strings.Contains(logContent, "Alice") || strings.Contains(logContent, "alice@example.com") {
|
||||||
|
t.Fatal("expected audit log to exclude PII")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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:
|
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
||||||
//
|
//
|
||||||
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
|
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
|
||||||
func CustomerHandler(applier ProfileApplier) http.Handler {
|
func CustomerHandler(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) http.Handler {
|
||||||
s := NewCustomerServer(applier)
|
s := NewCustomerServer(applier, auditLogPath, deleter...)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /", s.handleCreateCustomer)
|
mux.HandleFunc("POST /", s.handleCreateCustomer)
|
||||||
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
||||||
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
||||||
|
mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer)
|
||||||
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
||||||
mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress)
|
mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress)
|
||||||
mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress)
|
mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress)
|
||||||
@@ -104,9 +105,11 @@ func CustomerHandler(applier ProfileApplier) http.Handler {
|
|||||||
|
|
||||||
// Options controls optional features of the combined handler.
|
// Options controls optional features of the combined handler.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
|
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
|
||||||
OrderApplier OrderApplier // optional order creation for complete endpoint
|
OrderApplier OrderApplier // optional order creation for complete endpoint
|
||||||
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
|
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
|
||||||
|
CredentialDeleter CredentialDeleter // optional, for deleting login credentials during GDPR erasure
|
||||||
|
ProfileAuditLog string // file path for GDPR right-to-erasure audit log
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns an http.Handler that serves all mounted UCP endpoints under
|
// Handler returns an http.Handler that serves all mounted UCP endpoints under
|
||||||
@@ -148,10 +151,11 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
|||||||
|
|
||||||
// Customer endpoints (optional)
|
// Customer endpoints (optional)
|
||||||
if opts.ProfileApplier != nil {
|
if opts.ProfileApplier != nil {
|
||||||
customerSrv := NewCustomerServer(opts.ProfileApplier)
|
customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
|
||||||
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
|
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
|
||||||
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
||||||
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
||||||
|
mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer)
|
||||||
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
||||||
mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress)
|
mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress)
|
||||||
mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress)
|
mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress)
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ type ProfileApplier interface {
|
|||||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CredentialDeleter represents the subset of the credential store needed to delete credentials.
|
||||||
|
type CredentialDeleter interface {
|
||||||
|
Delete(ctx context.Context, email string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// OrderApplier is the interface the UCP checkout adapter uses to create a real
|
// OrderApplier is the interface the UCP checkout adapter uses to create a real
|
||||||
// order from a completed checkout session. It abstracts over the transport
|
// order from a completed checkout session. It abstracts over the transport
|
||||||
// (direct grain pool, HTTP, etc.) so the adapter is portable.
|
// (direct grain pool, HTTP, etc.) so the adapter is portable.
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/control"
|
messages "git.k6n.net/mats/go-cart-actor/proto/control"
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"go.opentelemetry.io/otel/metric"
|
||||||
@@ -31,7 +30,6 @@ const name = "grpc_server"
|
|||||||
var (
|
var (
|
||||||
tracer = otel.Tracer(name)
|
tracer = otel.Tracer(name)
|
||||||
meter = otel.Meter(name)
|
meter = otel.Meter(name)
|
||||||
logger = otelslog.NewLogger(name)
|
|
||||||
pingCalls metric.Int64Counter
|
pingCalls metric.Int64Counter
|
||||||
negotiateCalls metric.Int64Counter
|
negotiateCalls metric.Int64Counter
|
||||||
getLocalActorIdsCalls metric.Int64Counter
|
getLocalActorIdsCalls metric.Int64Counter
|
||||||
@@ -88,7 +86,6 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
|
|||||||
attribute.String("host", req.Host),
|
attribute.String("host", req.Host),
|
||||||
attribute.Int("id_count", len(req.Ids)),
|
attribute.Int("id_count", len(req.Ids)),
|
||||||
)
|
)
|
||||||
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
|
|
||||||
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||||
|
|
||||||
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
||||||
@@ -139,7 +136,6 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
|
|||||||
attribute.String("host", req.Host),
|
attribute.String("host", req.Host),
|
||||||
attribute.Int("id_count", len(req.Ids)),
|
attribute.Int("id_count", len(req.Ids)),
|
||||||
)
|
)
|
||||||
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
|
|
||||||
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||||
|
|
||||||
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
||||||
@@ -215,7 +211,6 @@ func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.Negotiat
|
|||||||
attribute.String("component", "controlplane"),
|
attribute.String("component", "controlplane"),
|
||||||
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
||||||
)
|
)
|
||||||
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
|
|
||||||
negotiateCalls.Add(ctx, 1)
|
negotiateCalls.Add(ctx, 1)
|
||||||
|
|
||||||
s.pool.Negotiate(req.KnownHosts)
|
s.pool.Negotiate(req.KnownHosts)
|
||||||
@@ -231,7 +226,6 @@ func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.E
|
|||||||
attribute.String("component", "controlplane"),
|
attribute.String("component", "controlplane"),
|
||||||
attribute.Int("id_count", len(ids)),
|
attribute.Int("id_count", len(ids)),
|
||||||
)
|
)
|
||||||
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
|
|
||||||
getLocalActorIdsCalls.Add(ctx, 1)
|
getLocalActorIdsCalls.Add(ctx, 1)
|
||||||
|
|
||||||
return &messages.ActorIdsReply{Ids: ids}, nil
|
return &messages.ActorIdsReply{Ids: ids}, nil
|
||||||
@@ -246,7 +240,6 @@ func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNot
|
|||||||
attribute.String("component", "controlplane"),
|
attribute.String("component", "controlplane"),
|
||||||
attribute.String("host", host),
|
attribute.String("host", host),
|
||||||
)
|
)
|
||||||
logger.InfoContext(ctx, "closing notice", "host", host)
|
|
||||||
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
||||||
|
|
||||||
if host != "" {
|
if host != "" {
|
||||||
|
|||||||
+13
-6
@@ -54,6 +54,8 @@ type CartItem struct {
|
|||||||
OrderReference string `json:"orderReference,omitempty"`
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
IsSubscribed bool `json:"isSubscribed,omitempty"`
|
IsSubscribed bool `json:"isSubscribed,omitempty"`
|
||||||
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
|
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
|
||||||
|
InventoryTracked bool `json:"inventoryTracked,omitempty"`
|
||||||
|
DropShip bool `json:"dropShip,omitempty"`
|
||||||
|
|
||||||
// Extra holds arbitrary dynamic product data. Its keys are flattened onto
|
// Extra holds arbitrary dynamic product data. Its keys are flattened onto
|
||||||
// the item object in JSON (see cart_item_json.go), so they are returned
|
// the item object in JSON (see cart_item_json.go), so they are returned
|
||||||
@@ -152,12 +154,13 @@ type CartGrain struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Voucher struct {
|
type Voucher struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Applied bool `json:"applied"`
|
Applied bool `json:"applied"`
|
||||||
Rules []string `json:"rules"`
|
Rules []string `json:"rules"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Id uint32 `json:"id"`
|
Id uint32 `json:"id"`
|
||||||
Value int64 `json:"value"`
|
Value int64 `json:"value"`
|
||||||
|
BypassedByPromotions bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||||
@@ -298,6 +301,10 @@ func (c *CartGrain) UpdateTotals() {
|
|||||||
_, ok := voucher.AppliesTo(c)
|
_, ok := voucher.AppliesTo(c)
|
||||||
voucher.Applied = false
|
voucher.Applied = false
|
||||||
if ok {
|
if ok {
|
||||||
|
if voucher.BypassedByPromotions {
|
||||||
|
voucher.Applied = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||||
if c.TotalPrice.IncVat <= value.IncVat {
|
if c.TotalPrice.IncVat <= value.IncVat {
|
||||||
// don't apply discounts to more than the total price
|
// don't apply discounts to more than the total price
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ func (c *CartMutationContext) UseReservations(item *CartItem) bool {
|
|||||||
if item.ReservationEndTime != nil {
|
if item.ReservationEndTime != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if item.DropShip {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if item.InventoryTracked {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return item.Cgm == "55010"
|
return item.Cgm == "55010"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
|||||||
}
|
}
|
||||||
existing.Quantity += uint16(m.Quantity)
|
existing.Quantity += uint16(m.Quantity)
|
||||||
existing.Stock = uint16(m.Stock)
|
existing.Stock = uint16(m.Stock)
|
||||||
|
existing.InventoryTracked = m.InventoryTracked
|
||||||
|
existing.DropShip = m.DropShip
|
||||||
// If existing had nil store but new has one, adopt it.
|
// If existing had nil store but new has one, adopt it.
|
||||||
if existing.StoreId == nil && m.StoreId != nil {
|
if existing.StoreId == nil && m.StoreId != nil {
|
||||||
existing.StoreId = m.StoreId
|
existing.StoreId = m.StoreId
|
||||||
@@ -144,6 +146,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
|||||||
|
|
||||||
Extra: decodeExtra(m.ExtraJson),
|
Extra: decodeExtra(m.ExtraJson),
|
||||||
CustomFields: m.CustomFields,
|
CustomFields: m.CustomFields,
|
||||||
|
InventoryTracked: m.InventoryTracked,
|
||||||
|
DropShip: m.DropShip,
|
||||||
}
|
}
|
||||||
|
|
||||||
if needsReservation && c.UseReservations(cartItem) {
|
if needsReservation && c.UseReservations(cartItem) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
|
|||||||
if m.Quantity <= 0 {
|
if m.Quantity <= 0 {
|
||||||
// Remove the item
|
// Remove the item
|
||||||
itemToRemove := g.Items[foundIndex]
|
itemToRemove := g.Items[foundIndex]
|
||||||
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.Before(time.Now()) {
|
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.After(time.Now()) {
|
||||||
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
|
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
|
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
@@ -13,14 +15,33 @@ import (
|
|||||||
toolsWatch "k8s.io/client-go/tools/watch"
|
toolsWatch "k8s.io/client-go/tools/watch"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// InClusterNamespace returns the namespace the current pod runs in: POD_NAMESPACE
|
||||||
|
// (downward API) if set, else the service-account namespace file mounted into
|
||||||
|
// every pod. Empty means "not in a cluster" — callers can treat that as
|
||||||
|
// all-namespaces or single-node. Pass the result to NewK8sDiscoveryInNamespace
|
||||||
|
// to keep pod discovery (and thus the required RBAC) scoped to one namespace.
|
||||||
|
func InClusterNamespace() string {
|
||||||
|
if ns := os.Getenv("POD_NAMESPACE"); ns != "" {
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil {
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type K8sDiscovery struct {
|
type K8sDiscovery struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
client *kubernetes.Clientset
|
client *kubernetes.Clientset
|
||||||
listOptions metav1.ListOptions
|
listOptions metav1.ListOptions
|
||||||
|
// namespace scopes the pod list/watch. Empty means all namespaces (requires
|
||||||
|
// a cluster-scoped RBAC role); a specific namespace only needs a namespaced
|
||||||
|
// Role, which is the least-privilege option.
|
||||||
|
namespace string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *K8sDiscovery) Discover() ([]string, error) {
|
func (k *K8sDiscovery) Discover() ([]string, error) {
|
||||||
return k.DiscoverInNamespace("")
|
return k.DiscoverInNamespace(k.namespace)
|
||||||
}
|
}
|
||||||
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
|
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
|
||||||
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, k.listOptions)
|
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, k.listOptions)
|
||||||
@@ -46,7 +67,7 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
|||||||
ipsThatAreReady := make(map[string]bool)
|
ipsThatAreReady := make(map[string]bool)
|
||||||
m := sync.Mutex{}
|
m := sync.Mutex{}
|
||||||
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
|
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
|
||||||
return k.client.CoreV1().Pods("").Watch(k.ctx, k.listOptions)
|
return k.client.CoreV1().Pods(k.namespace).Watch(k.ctx, k.listOptions)
|
||||||
}
|
}
|
||||||
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
|
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,6 +98,8 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
|||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewK8sDiscovery watches pods across ALL namespaces (label-filtered via
|
||||||
|
// listOptions). It requires a cluster-scoped RBAC role for pod list/watch.
|
||||||
func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOptions) *K8sDiscovery {
|
func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOptions) *K8sDiscovery {
|
||||||
return &K8sDiscovery{
|
return &K8sDiscovery{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
@@ -84,3 +107,14 @@ func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOption
|
|||||||
listOptions: listOptions,
|
listOptions: listOptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewK8sDiscoveryInNamespace watches pods only within namespace, so it needs
|
||||||
|
// just a namespaced Role (pods: get/list/watch) rather than a ClusterRole.
|
||||||
|
func NewK8sDiscoveryInNamespace(client *kubernetes.Clientset, namespace string, listOptions metav1.ListOptions) *K8sDiscovery {
|
||||||
|
return &K8sDiscovery{
|
||||||
|
ctx: context.Background(),
|
||||||
|
client: client,
|
||||||
|
listOptions: listOptions,
|
||||||
|
namespace: namespace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -233,6 +233,103 @@ func HandleIssueRefund(o *OrderGrain, m *messages.IssueRefund) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleRequestExchange records an exchange request.
|
||||||
|
func HandleRequestExchange(o *OrderGrain, m *messages.RequestExchange) error {
|
||||||
|
if o.Status != StatusFulfilled && o.Status != StatusCompleted && o.Status != StatusPartiallyFulfilled {
|
||||||
|
return fmt.Errorf("order: cannot request an exchange in status %q", o.Status)
|
||||||
|
}
|
||||||
|
ex := Exchange{
|
||||||
|
ID: m.GetId(),
|
||||||
|
ReturnID: m.GetReturnId(),
|
||||||
|
Reason: m.GetReason(),
|
||||||
|
RequestedAt: msToString(m.GetAtMs()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Process return lines
|
||||||
|
for _, rl := range m.GetReturnLines() {
|
||||||
|
line := o.findLine(rl.GetReference())
|
||||||
|
if line == nil {
|
||||||
|
return fmt.Errorf("order: exchange return references unknown line %q", rl.GetReference())
|
||||||
|
}
|
||||||
|
ex.ReturnLines = append(ex.ReturnLines, FulfillmentEntry{
|
||||||
|
Reference: rl.GetReference(),
|
||||||
|
Quantity: int(rl.GetQuantity()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process new lines (replacement items)
|
||||||
|
for _, nl := range m.GetNewLines() {
|
||||||
|
line := Line{
|
||||||
|
Reference: nl.GetReference(),
|
||||||
|
Sku: nl.GetSku(),
|
||||||
|
Name: nl.GetName(),
|
||||||
|
Quantity: int(nl.GetQuantity()),
|
||||||
|
UnitPrice: nl.GetUnitPrice(),
|
||||||
|
TaxRate: int(nl.GetTaxRate()),
|
||||||
|
TotalAmount: nl.GetTotalAmount(),
|
||||||
|
TotalTax: nl.GetTotalTax(),
|
||||||
|
}
|
||||||
|
ex.NewLines = append(ex.NewLines, line)
|
||||||
|
|
||||||
|
// Add the replacement line to the order so it can be fulfilled
|
||||||
|
o.Lines = append(o.Lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Since there are new lines that are not yet fulfilled, transition order status back to partially fulfilled
|
||||||
|
o.Status = StatusPartiallyFulfilled
|
||||||
|
|
||||||
|
o.Exchanges = append(o.Exchanges, ex)
|
||||||
|
o.touch(ex.RequestedAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEditOrderDetails updates addresses or shipping price.
|
||||||
|
func HandleEditOrderDetails(o *OrderGrain, m *messages.EditOrderDetails) error {
|
||||||
|
if o.Status == StatusCompleted || o.Status == StatusCancelled || o.Status == StatusRefunded {
|
||||||
|
return fmt.Errorf("order: cannot edit details in status %q", o.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := m.GetShippingAddress(); len(s) > 0 {
|
||||||
|
o.ShippingAddress = append([]byte(nil), s...)
|
||||||
|
}
|
||||||
|
if b := m.GetBillingAddress(); len(b) > 0 {
|
||||||
|
o.BillingAddress = append([]byte(nil), b...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sp := m.GetShippingPrice(); sp > 0 {
|
||||||
|
found := false
|
||||||
|
var oldTotal int64
|
||||||
|
for i := range o.Lines {
|
||||||
|
if o.Lines[i].Name == "Delivery" {
|
||||||
|
oldTotal = o.Lines[i].TotalAmount
|
||||||
|
o.Lines[i].UnitPrice = sp
|
||||||
|
o.Lines[i].TotalAmount = sp * int64(o.Lines[i].Quantity)
|
||||||
|
o.Lines[i].TotalTax = ComputeTax(o.Lines[i].TotalAmount, o.Lines[i].TaxRate)
|
||||||
|
o.TotalAmount = o.TotalAmount - oldTotal + o.Lines[i].TotalAmount
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
line := Line{
|
||||||
|
Reference: "delivery",
|
||||||
|
Sku: "delivery",
|
||||||
|
Name: "Delivery",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: sp,
|
||||||
|
TaxRate: 25,
|
||||||
|
TotalAmount: sp,
|
||||||
|
TotalTax: ComputeTax(sp, 25),
|
||||||
|
}
|
||||||
|
o.Lines = append(o.Lines, line)
|
||||||
|
o.TotalAmount += sp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
o.touch(msToString(m.GetAtMs()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterMutations registers every order mutation handler with the registry so
|
// RegisterMutations registers every order mutation handler with the registry so
|
||||||
// the grain pool can apply and replay them.
|
// the grain pool can apply and replay them.
|
||||||
func RegisterMutations(reg actor.MutationRegistry) {
|
func RegisterMutations(reg actor.MutationRegistry) {
|
||||||
@@ -245,5 +342,9 @@ func RegisterMutations(reg actor.MutationRegistry) {
|
|||||||
actor.NewMutation(HandleCancelOrder),
|
actor.NewMutation(HandleCancelOrder),
|
||||||
actor.NewMutation(HandleRequestReturn),
|
actor.NewMutation(HandleRequestReturn),
|
||||||
actor.NewMutation(HandleIssueRefund),
|
actor.NewMutation(HandleIssueRefund),
|
||||||
|
actor.NewMutation(HandleRequestExchange),
|
||||||
|
actor.NewMutation(HandleEditOrderDetails),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,16 @@ type Return struct {
|
|||||||
RequestedAt string `json:"requestedAt,omitempty"`
|
RequestedAt string `json:"requestedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exchange records an exchange request.
|
||||||
|
type Exchange struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ReturnID string `json:"returnId"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
ReturnLines []FulfillmentEntry `json:"returnLines,omitempty"`
|
||||||
|
NewLines []Line `json:"newLines,omitempty"`
|
||||||
|
RequestedAt string `json:"requestedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Refund records a refund issued against the order.
|
// Refund records a refund issued against the order.
|
||||||
type Refund struct {
|
type Refund struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
@@ -137,8 +147,10 @@ type OrderGrain struct {
|
|||||||
Payments []*Payment `json:"payments,omitempty"`
|
Payments []*Payment `json:"payments,omitempty"`
|
||||||
Fulfillments []Fulfillment `json:"fulfillments,omitempty"`
|
Fulfillments []Fulfillment `json:"fulfillments,omitempty"`
|
||||||
Returns []Return `json:"returns,omitempty"`
|
Returns []Return `json:"returns,omitempty"`
|
||||||
|
Exchanges []Exchange `json:"exchanges,omitempty"`
|
||||||
Refunds []Refund `json:"refunds,omitempty"`
|
Refunds []Refund `json:"refunds,omitempty"`
|
||||||
|
|
||||||
|
|
||||||
CapturedAmount int64 `json:"capturedAmount"`
|
CapturedAmount int64 `json:"capturedAmount"`
|
||||||
RefundedAmount int64 `json:"refundedAmount"`
|
RefundedAmount int64 `json:"refundedAmount"`
|
||||||
|
|
||||||
|
|||||||
@@ -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(HandleLinkCart),
|
||||||
actor.NewMutation(HandleLinkCheckout),
|
actor.NewMutation(HandleLinkCheckout),
|
||||||
actor.NewMutation(HandleLinkOrder),
|
actor.NewMutation(HandleLinkOrder),
|
||||||
|
actor.NewMutation(HandleAnonymizeProfile),
|
||||||
)
|
)
|
||||||
return reg
|
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 (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
)
|
)
|
||||||
@@ -28,7 +30,7 @@ type Effect interface {
|
|||||||
// Apply mutates the cart for a qualifying action and returns the discount it
|
// Apply mutates the cart for a qualifying action and returns the discount it
|
||||||
// took (nil for non-monetary effects such as free shipping) plus whether
|
// took (nil for non-monetary effects such as free shipping) plus whether
|
||||||
// anything took effect worth recording.
|
// anything took effect worth recording.
|
||||||
Apply(g *cart.CartGrain, a Action) (discount *cart.Price, applied bool)
|
Apply(g *cart.CartGrain, rule PromotionRule, a Action) (discount *cart.Price, applied bool)
|
||||||
// Progress reports how far the cart is from (further) unlocking this action,
|
// Progress reports how far the cart is from (further) unlocking this action,
|
||||||
// as an open key/value payload. qualified says whether the owning rule
|
// as an open key/value payload. qualified says whether the owning rule
|
||||||
// currently applies, letting the effect distinguish "remaining to unlock"
|
// currently applies, letting the effect distinguish "remaining to unlock"
|
||||||
@@ -43,6 +45,8 @@ func defaultEffects() map[ActionType]Effect {
|
|||||||
ActionPercentageDiscount: percentageDiscountEffect{},
|
ActionPercentageDiscount: percentageDiscountEffect{},
|
||||||
ActionTieredDiscount: tieredDiscountEffect{},
|
ActionTieredDiscount: tieredDiscountEffect{},
|
||||||
ActionFreeShipping: freeShippingEffect{},
|
ActionFreeShipping: freeShippingEffect{},
|
||||||
|
ActionBuyXGetY: buyXGetYEffect{},
|
||||||
|
ActionBundleDiscount: bundleDiscountEffect{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@ func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationR
|
|||||||
}
|
}
|
||||||
recorded := false
|
recorded := false
|
||||||
if res.Applicable {
|
if res.Applicable {
|
||||||
if discount, applied := eff.Apply(g, a); applied {
|
if discount, applied := eff.Apply(g, res.Rule, a); applied {
|
||||||
entry.Discount = discount
|
entry.Discount = discount
|
||||||
recorded = true
|
recorded = true
|
||||||
}
|
}
|
||||||
@@ -106,7 +110,7 @@ type percentageDiscountEffect struct{}
|
|||||||
|
|
||||||
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
|
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
|
||||||
|
|
||||||
func (percentageDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
|
func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||||
d := applyPercentageDiscount(g, percentFromValue(a.Value))
|
d := applyPercentageDiscount(g, percentFromValue(a.Value))
|
||||||
return d, d != nil
|
return d, d != nil
|
||||||
}
|
}
|
||||||
@@ -124,7 +128,7 @@ type freeShippingEffect struct{}
|
|||||||
|
|
||||||
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
|
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
|
||||||
|
|
||||||
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ Action) (*cart.Price, bool) {
|
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, bool) {
|
||||||
return nil, true
|
return nil, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +147,7 @@ type tieredDiscountEffect struct{}
|
|||||||
|
|
||||||
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
|
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
|
||||||
|
|
||||||
func (tieredDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
|
func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||||
d := applyTieredDiscount(g, a)
|
d := applyTieredDiscount(g, a)
|
||||||
return d, d != nil
|
return d, d != nil
|
||||||
}
|
}
|
||||||
@@ -366,3 +370,361 @@ func firstNum(m map[string]interface{}, keys ...string) float64 {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// buyXGetYEffect implementation
|
||||||
|
// ----------------------------
|
||||||
|
|
||||||
|
type buyXGetYEffect struct{}
|
||||||
|
|
||||||
|
func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY }
|
||||||
|
|
||||||
|
type unitItem struct {
|
||||||
|
item *cart.CartItem
|
||||||
|
price int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
|
||||||
|
config := a.Config
|
||||||
|
buy := int(firstNum(config, "buy"))
|
||||||
|
if buy <= 0 {
|
||||||
|
buy = 1
|
||||||
|
}
|
||||||
|
get := int(firstNum(config, "get"))
|
||||||
|
if get <= 0 {
|
||||||
|
get = 1
|
||||||
|
}
|
||||||
|
discount := firstNum(config, "discount")
|
||||||
|
if discount <= 0 {
|
||||||
|
discount = 100.0 // default free
|
||||||
|
}
|
||||||
|
|
||||||
|
eligible := eligibleItems(rule, g)
|
||||||
|
var units []unitItem
|
||||||
|
for _, item := range eligible {
|
||||||
|
for k := uint16(0); k < item.Quantity; k++ {
|
||||||
|
units = append(units, unitItem{
|
||||||
|
item: item,
|
||||||
|
price: item.Price.IncVat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n := len(units)
|
||||||
|
numFree := (n / (buy + get)) * get
|
||||||
|
if numFree <= 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort units by price ascending so we discount the cheapest ones
|
||||||
|
slices.SortFunc(units, func(x, y unitItem) int {
|
||||||
|
if x.price < y.price {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if x.price > y.price {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
totalDiscount := cart.NewPrice()
|
||||||
|
for i := 0; i < numFree; i++ {
|
||||||
|
unitDiscount := scalePrice(&units[i].item.Price, discount)
|
||||||
|
totalDiscount.Add(*unitDiscount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalDiscount.IncVat <= 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// Never discount below zero.
|
||||||
|
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||||
|
totalDiscount = g.TotalPrice
|
||||||
|
}
|
||||||
|
g.TotalDiscount.Add(*totalDiscount)
|
||||||
|
g.TotalPrice.Subtract(*totalDiscount)
|
||||||
|
return totalDiscount, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (buyXGetYEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// bundleDiscountEffect implementation
|
||||||
|
// ----------------------------
|
||||||
|
|
||||||
|
type bundleDiscountEffect struct{}
|
||||||
|
|
||||||
|
func (bundleDiscountEffect) Type() ActionType { return ActionBundleDiscount }
|
||||||
|
|
||||||
|
func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||||
|
if a.BundleConfig == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Represent all cart items as individual units
|
||||||
|
type bundleUnit struct {
|
||||||
|
item *cart.CartItem
|
||||||
|
used bool
|
||||||
|
}
|
||||||
|
var units []*bundleUnit
|
||||||
|
for _, item := range g.Items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k := uint16(0); k < item.Quantity; k++ {
|
||||||
|
units = append(units, &bundleUnit{
|
||||||
|
item: item,
|
||||||
|
used: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Greedy match bundles
|
||||||
|
var formedBundles [][]*cart.CartItem
|
||||||
|
for {
|
||||||
|
// Attempt to form one bundle
|
||||||
|
var matchedUnits []*bundleUnit
|
||||||
|
possible := true
|
||||||
|
|
||||||
|
for _, container := range a.BundleConfig.Containers {
|
||||||
|
needed := container.Quantity
|
||||||
|
found := 0
|
||||||
|
var containerMatched []*bundleUnit
|
||||||
|
|
||||||
|
// Find unused units matching container's qualifyingRules
|
||||||
|
for _, u := range units {
|
||||||
|
if u.used {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if this unit is already matched in this bundle instance to avoid double counting
|
||||||
|
alreadyMatched := false
|
||||||
|
for _, mu := range matchedUnits {
|
||||||
|
if mu == u {
|
||||||
|
alreadyMatched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alreadyMatched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchQualifyingRule(container.QualifyingRules, u.item) {
|
||||||
|
containerMatched = append(containerMatched, u)
|
||||||
|
found++
|
||||||
|
if found == needed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found < needed {
|
||||||
|
if a.BundleConfig.RequireAllContainers {
|
||||||
|
possible = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
matchedUnits = append(matchedUnits, containerMatched...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !possible || len(matchedUnits) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark matched units as used
|
||||||
|
var bundleItems []*cart.CartItem
|
||||||
|
for _, mu := range matchedUnits {
|
||||||
|
mu.used = true
|
||||||
|
bundleItems = append(bundleItems, mu.item)
|
||||||
|
}
|
||||||
|
formedBundles = append(formedBundles, bundleItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(formedBundles) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDiscount := cart.NewPrice()
|
||||||
|
for _, bundle := range formedBundles {
|
||||||
|
// Calculate the original bundle price
|
||||||
|
bundlePrice := cart.NewPrice()
|
||||||
|
for _, item := range bundle {
|
||||||
|
// Add a single unit's price
|
||||||
|
bundlePrice.Add(item.Price)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundlePrice.IncVat <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundleDiscount *cart.Price
|
||||||
|
switch a.BundleConfig.Pricing.Type {
|
||||||
|
case "fixed_price":
|
||||||
|
targetPriceOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||||
|
if bundlePrice.IncVat > targetPriceOre {
|
||||||
|
pct := float64(bundlePrice.IncVat-targetPriceOre) / float64(bundlePrice.IncVat) * 100
|
||||||
|
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||||
|
}
|
||||||
|
case "percentage_discount":
|
||||||
|
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
|
||||||
|
case "fixed_discount":
|
||||||
|
discountOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||||
|
pct := float64(discountOre) / float64(bundlePrice.IncVat) * 100
|
||||||
|
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
|
||||||
|
totalDiscount.Add(*bundleDiscount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalDiscount.IncVat <= 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// Never discount below zero.
|
||||||
|
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||||
|
totalDiscount = g.TotalPrice
|
||||||
|
}
|
||||||
|
g.TotalDiscount.Add(*totalDiscount)
|
||||||
|
g.TotalPrice.Subtract(*totalDiscount)
|
||||||
|
return totalDiscount, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Helpers
|
||||||
|
// ----------------------------
|
||||||
|
|
||||||
|
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
|
||||||
|
var categories []string
|
||||||
|
var productIDs []string
|
||||||
|
|
||||||
|
var walk func(conds Conditions)
|
||||||
|
walk = func(conds Conditions) {
|
||||||
|
for _, c := range conds {
|
||||||
|
switch v := c.(type) {
|
||||||
|
case ConditionGroup:
|
||||||
|
walk(v.Conditions)
|
||||||
|
case BaseCondition:
|
||||||
|
if v.Type == CondProductCategory {
|
||||||
|
if s, ok := v.Value.AsString(); ok {
|
||||||
|
categories = append(categories, strings.ToLower(s))
|
||||||
|
} else if arr, ok := v.Value.AsStringSlice(); ok {
|
||||||
|
for _, s := range arr {
|
||||||
|
categories = append(categories, strings.ToLower(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if v.Type == CondProductID {
|
||||||
|
if s, ok := v.Value.AsString(); ok {
|
||||||
|
productIDs = append(productIDs, strings.ToLower(s))
|
||||||
|
} else if arr, ok := v.Value.AsStringSlice(); ok {
|
||||||
|
for _, s := range arr {
|
||||||
|
productIDs = append(productIDs, strings.ToLower(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(rule.Conditions)
|
||||||
|
|
||||||
|
var out []*cart.CartItem
|
||||||
|
for _, item := range g.Items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match := true
|
||||||
|
if len(categories) > 0 {
|
||||||
|
cat := ""
|
||||||
|
if item.Meta != nil {
|
||||||
|
cat = strings.ToLower(item.Meta.Category)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, c := range categories {
|
||||||
|
if c == cat {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
match = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(productIDs) > 0 {
|
||||||
|
sku := strings.ToLower(item.Sku)
|
||||||
|
found := false
|
||||||
|
for _, p := range productIDs {
|
||||||
|
if p == sku {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
match = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchQualifyingRule(rule BundleQualifyingRules, item *cart.CartItem) bool {
|
||||||
|
if item == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
valStr, isStr := rule.Value.(string)
|
||||||
|
var valSlice []string
|
||||||
|
if !isStr {
|
||||||
|
if rawSlice, ok := rule.Value.([]interface{}); ok {
|
||||||
|
for _, val := range rawSlice {
|
||||||
|
if s, ok := val.(string); ok {
|
||||||
|
valSlice = append(valSlice, strings.ToLower(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strSlice, ok := rule.Value.([]string); ok {
|
||||||
|
for _, s := range strSlice {
|
||||||
|
valSlice = append(valSlice, strings.ToLower(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
valStr = strings.ToLower(valStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rule.Type {
|
||||||
|
case "category":
|
||||||
|
if item.Meta == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cat := strings.ToLower(item.Meta.Category)
|
||||||
|
if isStr {
|
||||||
|
return cat == valStr
|
||||||
|
}
|
||||||
|
for _, c := range valSlice {
|
||||||
|
if cat == c {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case "product_ids":
|
||||||
|
sku := strings.ToLower(item.Sku)
|
||||||
|
if isStr {
|
||||||
|
return sku == valStr
|
||||||
|
}
|
||||||
|
for _, s := range valSlice {
|
||||||
|
if sku == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case "all":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
CustomerLifetimeValue float64
|
||||||
OrderCount int
|
OrderCount int
|
||||||
Now time.Time
|
Now time.Time
|
||||||
|
CouponCodes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContextOption allows customization of fields when building a PromotionEvalContext.
|
// ContextOption allows customization of fields when building a PromotionEvalContext.
|
||||||
@@ -67,6 +68,11 @@ func WithNow(t time.Time) ContextOption {
|
|||||||
return func(c *PromotionEvalContext) { c.Now = t }
|
return func(c *PromotionEvalContext) { c.Now = t }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithCouponCodes sets active coupon codes.
|
||||||
|
func WithCouponCodes(codes []string) ContextOption {
|
||||||
|
return func(c *PromotionEvalContext) { c.CouponCodes = codes }
|
||||||
|
}
|
||||||
|
|
||||||
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
|
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
|
||||||
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
|
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
|
||||||
ctx := &PromotionEvalContext{
|
ctx := &PromotionEvalContext{
|
||||||
@@ -74,6 +80,7 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
|
|||||||
CartTotalIncVat: 0,
|
CartTotalIncVat: 0,
|
||||||
TotalItemQuantity: 0,
|
TotalItemQuantity: 0,
|
||||||
Now: time.Now(),
|
Now: time.Now(),
|
||||||
|
CouponCodes: make([]string, 0),
|
||||||
}
|
}
|
||||||
if g.TotalPrice != nil {
|
if g.TotalPrice != nil {
|
||||||
ctx.CartTotalIncVat = g.TotalPrice.IncVat
|
ctx.CartTotalIncVat = g.TotalPrice.IncVat
|
||||||
@@ -91,6 +98,9 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
|
|||||||
})
|
})
|
||||||
ctx.TotalItemQuantity += uint32(it.Quantity)
|
ctx.TotalItemQuantity += uint32(it.Quantity)
|
||||||
}
|
}
|
||||||
|
for _, v := range g.Vouchers {
|
||||||
|
ctx.CouponCodes = append(ctx.CouponCodes, v.Code)
|
||||||
|
}
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(ctx)
|
o(ctx)
|
||||||
}
|
}
|
||||||
@@ -106,18 +116,29 @@ type PromotionService struct {
|
|||||||
// even one that has already applied (e.g. a tiered discount at 5% pointing at
|
// even one that has already applied (e.g. a tiered discount at 5% pointing at
|
||||||
// the next 9% tier). Register a new Effect to add a new action type.
|
// the next 9% tier). Register a new Effect to add a new action type.
|
||||||
effects map[ActionType]Effect
|
effects map[ActionType]Effect
|
||||||
|
// DefaultTaxProvider provides the VAT rate used when constructing synthetic
|
||||||
|
// carts for stateless evaluation and preview. When nil, falls back to 25 %.
|
||||||
|
DefaultTaxProvider cart.TaxProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPromotionService constructs a PromotionService with an optional tracer and
|
// NewPromotionService constructs a PromotionService with an optional tracer and
|
||||||
// the built-in effect handlers.
|
// the built-in effect handlers.
|
||||||
func NewPromotionService(t Tracer) *PromotionService {
|
// NewPromotionService constructs a PromotionService with an optional tracer.
|
||||||
|
// When tp is non-nil, it is used as DefaultTaxProvider for synthetic cart
|
||||||
|
// construction (stateless evaluation, preview); otherwise the service falls
|
||||||
|
// back to 25 % VAT.
|
||||||
|
func NewPromotionService(t Tracer, tp ...cart.TaxProvider) *PromotionService {
|
||||||
if t == nil {
|
if t == nil {
|
||||||
t = NoopTracer{}
|
t = NoopTracer{}
|
||||||
}
|
}
|
||||||
return &PromotionService{
|
s := &PromotionService{
|
||||||
tracer: t,
|
tracer: t,
|
||||||
effects: defaultEffects(),
|
effects: defaultEffects(),
|
||||||
}
|
}
|
||||||
|
if len(tp) > 0 && tp[0] != nil {
|
||||||
|
s.DefaultTaxProvider = tp[0]
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterEffect adds (or overrides) the handler for an action type, so new
|
// RegisterEffect adds (or overrides) the handler for an action type, so new
|
||||||
@@ -360,11 +381,22 @@ func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
|
|||||||
return evalDayOfWeek(ctx.Now, b)
|
return evalDayOfWeek(ctx.Now, b)
|
||||||
case CondTimeOfDay:
|
case CondTimeOfDay:
|
||||||
return evalTimeOfDay(ctx.Now, b)
|
return evalTimeOfDay(ctx.Now, b)
|
||||||
|
case CondCouponCode:
|
||||||
|
return evalAnyCouponMatch(ctx.CouponCodes, b)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func evalAnyCouponMatch(codes []string, b BaseCondition) bool {
|
||||||
|
for _, code := range codes {
|
||||||
|
if evalStringCompare(code, b) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
|
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||||
if slices.ContainsFunc(ctx.Items, pred) {
|
if slices.ContainsFunc(ctx.Items, pred) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -442,6 +442,45 @@ func TestDateRangeCondition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCouponCodeCondition(t *testing.T) {
|
||||||
|
rule := PromotionRule{
|
||||||
|
ID: "coupon-promo",
|
||||||
|
Name: "Coupon Promo",
|
||||||
|
Status: StatusActive,
|
||||||
|
Conditions: Conditions{
|
||||||
|
BaseCondition{
|
||||||
|
ID: "c",
|
||||||
|
Type: CondCouponCode,
|
||||||
|
Operator: OpEquals,
|
||||||
|
Value: cvString("SAVE50"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewPromotionService(nil)
|
||||||
|
|
||||||
|
// Context with matching coupon code
|
||||||
|
ctxMatched := &PromotionEvalContext{
|
||||||
|
CouponCodes: []string{"SAVE50"},
|
||||||
|
Now: time.Now(),
|
||||||
|
}
|
||||||
|
resMatched := svc.EvaluateRule(rule, ctxMatched)
|
||||||
|
if !resMatched.Applicable {
|
||||||
|
t.Errorf("expected coupon code rule to apply with SAVE50")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context without matching coupon code
|
||||||
|
ctxUnmatched := &PromotionEvalContext{
|
||||||
|
CouponCodes: []string{"SAVE20"},
|
||||||
|
Now: time.Now(),
|
||||||
|
}
|
||||||
|
resUnmatched := svc.EvaluateRule(rule, ctxUnmatched)
|
||||||
|
if resUnmatched.Applicable {
|
||||||
|
t.Errorf("expected coupon code rule NOT to apply with SAVE20")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Utilities -------------------------------------------------------------
|
// --- Utilities -------------------------------------------------------------
|
||||||
|
|
||||||
func ptr(s string) *string { return &s }
|
func ptr(s string) *string { return &s }
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ type EvaluateResponse struct {
|
|||||||
// Evaluate runs rules against a synthetic cart built from req and returns the
|
// Evaluate runs rules against a synthetic cart built from req and returns the
|
||||||
// resulting totals and effects without touching any real cart state.
|
// resulting totals and effects without touching any real cart state.
|
||||||
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
|
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
|
||||||
g := req.toCart()
|
g := req.toCart(s.DefaultTaxProvider)
|
||||||
g.UpdateTotals()
|
g.UpdateTotals()
|
||||||
ctx := NewContextFromCart(g, req.contextOptions()...)
|
ctx := NewContextFromCart(g, req.contextOptions()...)
|
||||||
results, _ := s.EvaluateAll(rules, ctx)
|
results, _ := s.EvaluateAll(rules, ctx)
|
||||||
@@ -65,7 +65,8 @@ func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// toCart materialises the request into a throwaway CartGrain.
|
// toCart materialises the request into a throwaway CartGrain.
|
||||||
func (req EvaluateRequest) toCart() *cart.CartGrain {
|
// tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %.
|
||||||
|
func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if req.Now != nil {
|
if req.Now != nil {
|
||||||
now = *req.Now
|
now = *req.Now
|
||||||
@@ -78,7 +79,7 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
|
|||||||
}
|
}
|
||||||
vat := it.VatRate
|
vat := it.VatRate
|
||||||
if vat == 0 {
|
if vat == 0 {
|
||||||
vat = 25
|
vat = defaultVatRate(tp)
|
||||||
}
|
}
|
||||||
item := &cart.CartItem{
|
item := &cart.CartItem{
|
||||||
Sku: it.Sku,
|
Sku: it.Sku,
|
||||||
@@ -91,15 +92,25 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
|
|||||||
g.Items = append(g.Items, item)
|
g.Items = append(g.Items, item)
|
||||||
}
|
}
|
||||||
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
||||||
|
vat := defaultVatRate(tp)
|
||||||
g.Items = append(g.Items, &cart.CartItem{
|
g.Items = append(g.Items, &cart.CartItem{
|
||||||
Sku: "synthetic",
|
Sku: "synthetic",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, 25),
|
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, vat),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultVatRate returns the default VAT rate (as a float32 for NewPriceFromIncVat)
|
||||||
|
// from the provider, or 25 if none is configured.
|
||||||
|
func defaultVatRate(tp cart.TaxProvider) float32 {
|
||||||
|
if tp == nil {
|
||||||
|
return 25
|
||||||
|
}
|
||||||
|
return float32(tp.DefaultTaxRate(""))
|
||||||
|
}
|
||||||
|
|
||||||
// contextOptions maps the optional customer/time fields onto context options.
|
// contextOptions maps the optional customer/time fields onto context options.
|
||||||
func (req EvaluateRequest) contextOptions() []ContextOption {
|
func (req EvaluateRequest) contextOptions() []ContextOption {
|
||||||
var opts []ContextOption
|
var opts []ContextOption
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ func (s *Server) initialize(params json.RawMessage) map[string]any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultVatRate returns the default VAT rate from the eval service's
|
||||||
|
// configured tax provider, falling back to 25 if none is set.
|
||||||
|
func (s *Server) defaultVatRate() float32 {
|
||||||
|
if s.eval == nil || s.eval.DefaultTaxProvider == nil {
|
||||||
|
return 25
|
||||||
|
}
|
||||||
|
return float32(s.eval.DefaultTaxProvider.DefaultTaxRate(""))
|
||||||
|
}
|
||||||
|
|
||||||
func isBatch(body []byte) bool {
|
func isBatch(body []byte) bool {
|
||||||
for _, b := range body {
|
for _, b := range body {
|
||||||
switch b {
|
switch b {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func (s *Server) buildPublicTools() []tool {
|
|||||||
if err := decode(args, &a); err != nil {
|
if err := decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
|
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||||
if a.CustomerSegment != "" {
|
if a.CustomerSegment != "" {
|
||||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||||
@@ -141,7 +141,7 @@ func (s *Server) buildPublicTools() []tool {
|
|||||||
rules = filtered
|
rules = filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
|
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||||
if a.CustomerSegment != "" {
|
if a.CustomerSegment != "" {
|
||||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ func (s *Server) buildTools() []tool {
|
|||||||
if err := decode(args, &a); err != nil {
|
if err := decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
|
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||||
if a.CustomerSegment != "" {
|
if a.CustomerSegment != "" {
|
||||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||||
@@ -210,15 +210,19 @@ func (s *Server) buildTools() []tool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// syntheticCart builds a one-line cart whose gross total is incVat öre (25% VAT
|
// syntheticCart builds a one-line cart whose gross total is incVat ore so the
|
||||||
// assumed) so the promotion engine can be evaluated against an arbitrary total.
|
// promotion engine can be evaluated against an arbitrary total. defaultVatRate
|
||||||
func syntheticCart(incVat int64, qty int) *cart.CartGrain {
|
// is the VAT rate as a float32 percentage (e.g. 25 = 25 %).
|
||||||
|
func syntheticCart(incVat int64, qty int, defaultVatRate float32) *cart.CartGrain {
|
||||||
if qty <= 0 {
|
if qty <= 0 {
|
||||||
qty = 1
|
qty = 1
|
||||||
}
|
}
|
||||||
|
if defaultVatRate == 0 {
|
||||||
|
defaultVatRate = 25
|
||||||
|
}
|
||||||
g := cart.NewCartGrain(0, time.Now())
|
g := cart.NewCartGrain(0, time.Now())
|
||||||
g.Items = []*cart.CartItem{
|
g.Items = []*cart.CartItem{
|
||||||
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, 25)},
|
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, defaultVatRate)},
|
||||||
}
|
}
|
||||||
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
|
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
|
||||||
// pricing a single unit at the full total.
|
// pricing a single unit at the full total.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const (
|
|||||||
CondDateRange ConditionType = "date_range"
|
CondDateRange ConditionType = "date_range"
|
||||||
CondDayOfWeek ConditionType = "day_of_week"
|
CondDayOfWeek ConditionType = "day_of_week"
|
||||||
CondTimeOfDay ConditionType = "time_of_day"
|
CondTimeOfDay ConditionType = "time_of_day"
|
||||||
|
CondCouponCode ConditionType = "coupon_code"
|
||||||
CondGroup ConditionType = "group" // synthetic value for groups
|
CondGroup ConditionType = "group" // synthetic value for groups
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/control"
|
messages "git.k6n.net/mats/go-cart-actor/proto/control"
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -39,7 +38,6 @@ const name = "proxy"
|
|||||||
var (
|
var (
|
||||||
tracer = otel.Tracer(name)
|
tracer = otel.Tracer(name)
|
||||||
meter = otel.Meter(name)
|
meter = otel.Meter(name)
|
||||||
logger = otelslog.NewLogger(name)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockResponseWriter implements http.ResponseWriter to capture responses for proxy calls.
|
// MockResponseWriter implements http.ResponseWriter to capture responses for proxy calls.
|
||||||
@@ -292,7 +290,6 @@ func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request,
|
|||||||
attribute.String("method", r.Method),
|
attribute.String("method", r.Method),
|
||||||
attribute.String("target", target),
|
attribute.String("target", target),
|
||||||
)
|
)
|
||||||
logger.InfoContext(ctx, "proxying request", "cartid", id, "host", h.host, "method", r.Method)
|
|
||||||
|
|
||||||
var bdy io.Reader = r.Body
|
var bdy io.Reader = r.Body
|
||||||
if customBody != nil {
|
if customBody != nil {
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,22 +6,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||||
"go.opentelemetry.io/otel/log/global"
|
"go.opentelemetry.io/otel/log/global"
|
||||||
"go.opentelemetry.io/otel/propagation"
|
"go.opentelemetry.io/otel/propagation"
|
||||||
"go.opentelemetry.io/otel/sdk/log"
|
|
||||||
"go.opentelemetry.io/otel/sdk/metric"
|
"go.opentelemetry.io/otel/sdk/metric"
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
"go.opentelemetry.io/otel/sdk/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
// SetupOTelSDK bootstraps the OpenTelemetry pipeline (propagator + traces +
|
||||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
// metrics, and an opt-in logger — see NewLoggerProvider). It is shared by every
|
||||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
// service main package; the service name comes from OTEL_RESOURCE_ATTRIBUTES, so
|
||||||
|
// the same setup works everywhere. If it returns no error, call the returned
|
||||||
|
// shutdown for proper cleanup.
|
||||||
|
func SetupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||||
var shutdownFuncs []func(context.Context) error
|
var shutdownFuncs []func(context.Context) error
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
// shutdown calls the registered cleanup functions, joining their errors.
|
||||||
shutdown := func(ctx context.Context) error {
|
shutdown := func(ctx context.Context) error {
|
||||||
var err error
|
var err error
|
||||||
for _, fn := range shutdownFuncs {
|
for _, fn := range shutdownFuncs {
|
||||||
@@ -31,12 +33,12 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleErr runs shutdown and surfaces all errors.
|
||||||
handleErr := func(inErr error) {
|
handleErr := func(inErr error) {
|
||||||
err = errors.Join(inErr, shutdown(ctx))
|
err = errors.Join(inErr, shutdown(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
prop := newPropagator()
|
otel.SetTextMapPropagator(newPropagator())
|
||||||
otel.SetTextMapPropagator(prop)
|
|
||||||
|
|
||||||
tracerProvider, err := newTracerProvider()
|
tracerProvider, err := newTracerProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,13 +56,17 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|||||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||||
otel.SetMeterProvider(meterProvider)
|
otel.SetMeterProvider(meterProvider)
|
||||||
|
|
||||||
loggerProvider, err := newLoggerProvider()
|
// Logger provider is opt-in via OTEL_LOGS_ENABLED; nil means logs are off.
|
||||||
|
// Traces + metrics above are always on.
|
||||||
|
loggerProvider, err := NewLoggerProvider(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleErr(err)
|
handleErr(err)
|
||||||
return shutdown, err
|
return shutdown, err
|
||||||
}
|
}
|
||||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
if loggerProvider != nil {
|
||||||
global.SetLoggerProvider(loggerProvider)
|
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||||
|
global.SetLoggerProvider(loggerProvider)
|
||||||
|
}
|
||||||
|
|
||||||
return shutdown, err
|
return shutdown, err
|
||||||
}
|
}
|
||||||
@@ -77,12 +83,9 @@ func newTracerProvider() (*trace.TracerProvider, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return trace.NewTracerProvider(
|
||||||
tracerProvider := trace.NewTracerProvider(
|
trace.WithBatcher(traceExporter, trace.WithBatchTimeout(time.Second)),
|
||||||
trace.WithBatcher(traceExporter,
|
), nil
|
||||||
trace.WithBatchTimeout(time.Second)),
|
|
||||||
)
|
|
||||||
return tracerProvider, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
func newMeterProvider() (*metric.MeterProvider, error) {
|
||||||
@@ -90,19 +93,5 @@ func newMeterProvider() (*metric.MeterProvider, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter))), nil
|
||||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
|
||||||
return provider, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
|
||||||
logExporter, err := otlploggrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
loggerProvider := log.NewLoggerProvider(
|
|
||||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
|
||||||
)
|
|
||||||
return loggerProvider, nil
|
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,8 @@ message AddItem {
|
|||||||
// custom_fields holds optional user-supplied input fields for this line
|
// custom_fields holds optional user-supplied input fields for this line
|
||||||
// (e.g. engraving text, configurator notes), keyed by field name.
|
// (e.g. engraving text, configurator notes), keyed by field name.
|
||||||
map<string, string> custom_fields = 28;
|
map<string, string> custom_fields = 28;
|
||||||
|
bool inventory_tracked = 29;
|
||||||
|
bool drop_ship = 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RemoveItem { uint32 Id = 1; }
|
message RemoveItem { uint32 Id = 1; }
|
||||||
|
|||||||
+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"`
|
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
|
// custom_fields holds optional user-supplied input fields for this line
|
||||||
// (e.g. engraving text, configurator notes), keyed by field name.
|
// (e.g. engraving text, configurator notes), keyed by field name.
|
||||||
CustomFields map[string]string `protobuf:"bytes,28,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
CustomFields map[string]string `protobuf:"bytes,28,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||||
unknownFields protoimpl.UnknownFields
|
InventoryTracked bool `protobuf:"varint,29,opt,name=inventory_tracked,json=inventoryTracked,proto3" json:"inventory_tracked,omitempty"`
|
||||||
sizeCache protoimpl.SizeCache
|
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() {
|
func (x *AddItem) Reset() {
|
||||||
@@ -324,6 +326,20 @@ func (x *AddItem) GetCustomFields() map[string]string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *AddItem) GetInventoryTracked() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.InventoryTracked
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AddItem) GetDropShip() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.DropShip
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type RemoveItem struct {
|
type RemoveItem struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
|
Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
|
||||||
@@ -1113,7 +1129,7 @@ var file_cart_proto_rawDesc = string([]byte{
|
|||||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70,
|
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70,
|
||||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||||
0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72,
|
0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72,
|
||||||
0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xe0, 0x07, 0x0a, 0x07,
|
0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xaa, 0x08, 0x0a, 0x07,
|
||||||
0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74, 0x65, 0x6d, 0x5f,
|
0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74, 0x65, 0x6d, 0x5f,
|
||||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64,
|
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64,
|
||||||
0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01,
|
0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01,
|
||||||
@@ -1168,133 +1184,138 @@ var file_cart_proto_rawDesc = string([]byte{
|
|||||||
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74,
|
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74,
|
||||||
0x65, 0x6d, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45,
|
0x65, 0x6d, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45,
|
||||||
0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
||||||
0x64, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
0x64, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x69, 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x5f,
|
||||||
0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
|
0x74, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69,
|
||||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
|
0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x12,
|
||||||
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
|
0x1b, 0x0a, 0x09, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x73, 0x68, 0x69, 0x70, 0x18, 0x1e, 0x20, 0x01,
|
||||||
0x02, 0x38, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a,
|
0x28, 0x08, 0x52, 0x08, 0x64, 0x72, 0x6f, 0x70, 0x53, 0x68, 0x69, 0x70, 0x1a, 0x3f, 0x0a, 0x11,
|
||||||
0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70,
|
0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72,
|
||||||
0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65,
|
0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||||
0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c,
|
0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
|
||||||
0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02,
|
0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x09, 0x0a,
|
||||||
0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e,
|
0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f,
|
||||||
0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e,
|
0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49,
|
||||||
0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a,
|
0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f,
|
||||||
0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05,
|
0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f,
|
||||||
0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65,
|
0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||||
0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49,
|
0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
|
||||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22,
|
0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01,
|
||||||
0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69,
|
0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
||||||
0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02,
|
0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
||||||
0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d,
|
0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
|
||||||
0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e,
|
0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67,
|
0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e,
|
||||||
0x22, 0x27, 0x0a, 0x15, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02,
|
||||||
0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
|
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04,
|
||||||
0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x17, 0x53, 0x65,
|
0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65,
|
||||||
0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46,
|
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||||
0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x22, 0x27, 0x0a, 0x15, 0x52, 0x65,
|
||||||
0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x5d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f,
|
0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b,
|
||||||
0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x63,
|
0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52,
|
||||||
|
0x02, 0x69, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49,
|
||||||
|
0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12,
|
||||||
|
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12,
|
||||||
|
0x5d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73,
|
||||||
|
0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
||||||
|
0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x2e, 0x43,
|
||||||
|
0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
|
||||||
|
0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x3f,
|
||||||
|
0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e,
|
||||||
|
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||||
|
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
||||||
|
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22,
|
||||||
|
0x71, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41,
|
||||||
|
0x64, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x18, 0x01,
|
||||||
|
0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09,
|
||||||
|
0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
|
0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72,
|
||||||
|
0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01,
|
||||||
|
0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
|
||||||
|
0x63, 0x65, 0x22, 0x7c, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72,
|
||||||
|
0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||||
|
0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
|
||||||
|
0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x76, 0x6f,
|
||||||
|
0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09,
|
||||||
|
0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x20,
|
||||||
|
0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20,
|
||||||
|
0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||||
|
0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
||||||
|
0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69,
|
||||||
|
0x64, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73,
|
||||||
|
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12,
|
||||||
|
0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x02, 0x69,
|
||||||
|
0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e, 0x67,
|
||||||
|
0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x65,
|
||||||
|
0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e,
|
||||||
|
0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73,
|
||||||
|
0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x04, 0x64, 0x61,
|
||||||
|
0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||||
|
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04,
|
||||||
|
0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x22, 0xa8, 0x07, 0x0a, 0x08,
|
||||||
|
0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x40, 0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61,
|
||||||
|
0x72, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63,
|
||||||
|
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x65,
|
||||||
|
0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52,
|
||||||
|
0x09, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x12, 0x33, 0x0a, 0x08, 0x61, 0x64,
|
||||||
|
0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63,
|
||||||
|
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64,
|
||||||
|
0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x07, 0x61, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12,
|
||||||
|
0x3c, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x03,
|
||||||
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||||
|
0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x48,
|
||||||
|
0x00, 0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x48, 0x0a,
|
||||||
|
0x0f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79,
|
||||||
|
0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61,
|
||||||
|
0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x00, 0x52, 0x0e, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51,
|
||||||
|
0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3a, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x75,
|
||||||
|
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63,
|
||||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
|
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
|
||||||
0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69,
|
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x74, 0x55, 0x73, 0x65,
|
||||||
0x65, 0x6c, 0x64, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64,
|
0x72, 0x49, 0x64, 0x12, 0x4c, 0x0a, 0x11, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d,
|
||||||
0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69,
|
0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
|
||||||
0x65, 0x6c, 0x64, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69,
|
0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c,
|
||||||
0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
|
0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00,
|
||||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76,
|
0x52, 0x0f, 0x6c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e,
|
||||||
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
|
0x67, 0x12, 0x5f, 0x0a, 0x18, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x6c, 0x69, 0x6e, 0x65,
|
||||||
0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x71, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69,
|
0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x07, 0x20,
|
||||||
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x74,
|
0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||||
0x65, 0x6d, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d,
|
0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
||||||
0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x18,
|
0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x15, 0x72, 0x65, 0x6d,
|
||||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64,
|
0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69,
|
||||||
0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
|
0x6e, 0x67, 0x12, 0x51, 0x0a, 0x12, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
|
||||||
0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52,
|
0x6f, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20,
|
||||||
0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x22, 0x7c, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x56,
|
0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53,
|
||||||
0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01,
|
0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64,
|
||||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,
|
0x48, 0x00, 0x52, 0x11, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||||
0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
|
0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x66, 0x0a, 0x1b, 0x73, 0x65, 0x74, 0x5f, 0x6c, 0x69, 0x6e,
|
||||||
0x12, 0x22, 0x0a, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73,
|
0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69,
|
||||||
0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52,
|
0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x63, 0x61, 0x72,
|
||||||
0x75, 0x6c, 0x65, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74,
|
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69,
|
||||||
0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72,
|
0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
||||||
0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
|
0x64, 0x73, 0x48, 0x00, 0x52, 0x17, 0x73, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65,
|
||||||
0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
|
0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x3c, 0x0a,
|
||||||
0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65,
|
0x0b, 0x61, 0x64, 0x64, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x14, 0x20, 0x01,
|
||||||
0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65,
|
0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||||
0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52,
|
||||||
0x09, 0x48, 0x00, 0x52, 0x02, 0x69, 0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66,
|
0x0a, 0x61, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x45, 0x0a, 0x0e, 0x72,
|
||||||
0x66, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x15, 0x20,
|
||||||
0x52, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20,
|
0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||||
0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20,
|
0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
||||||
0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65,
|
0x72, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68,
|
||||||
0x12, 0x28, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
|
0x65, 0x72, 0x12, 0x6a, 0x0a, 0x1b, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x5f, 0x73, 0x75, 0x62,
|
||||||
0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
|
0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c,
|
||||||
0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69,
|
0x73, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d,
|
||||||
0x64, 0x22, 0xa8, 0x07, 0x0a, 0x08, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x40,
|
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75,
|
||||||
0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01,
|
0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c,
|
||||||
0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
0x73, 0x48, 0x00, 0x52, 0x19, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63,
|
||||||
0x65, 0x73, 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75,
|
0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x42, 0x06,
|
||||||
0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74,
|
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36,
|
||||||
0x12, 0x33, 0x0a, 0x08, 0x61, 0x64, 0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01,
|
0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61,
|
||||||
0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63,
|
||||||
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x07, 0x61, 0x64,
|
0x61, 0x72, 0x74, 0x3b, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||||
0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x3c, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f,
|
0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
0x69, 0x74, 0x65, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72,
|
|
||||||
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76,
|
|
||||||
0x65, 0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49,
|
|
||||||
0x74, 0x65, 0x6d, 0x12, 0x48, 0x0a, 0x0f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x71, 0x75,
|
|
||||||
0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x63,
|
|
||||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x68, 0x61,
|
|
||||||
0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x00, 0x52, 0x0e, 0x63,
|
|
||||||
0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3a, 0x0a,
|
|
||||||
0x0b, 0x73, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01,
|
|
||||||
0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
|
||||||
0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, 0x00, 0x52, 0x09,
|
|
||||||
0x73, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x4c, 0x0a, 0x11, 0x6c, 0x69, 0x6e,
|
|
||||||
0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x06,
|
|
||||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
|
||||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72,
|
|
||||||
0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x0f, 0x6c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d,
|
|
||||||
0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x5f, 0x0a, 0x18, 0x72, 0x65, 0x6d, 0x6f, 0x76,
|
|
||||||
0x65, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b,
|
|
||||||
0x69, 0x6e, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x63, 0x61, 0x72, 0x74,
|
|
||||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
|
|
||||||
0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48,
|
|
||||||
0x00, 0x52, 0x15, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65,
|
|
||||||
0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x51, 0x0a, 0x12, 0x73, 0x75, 0x62, 0x73,
|
|
||||||
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x08,
|
|
||||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
|
||||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
|
|
||||||
0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x48, 0x00, 0x52, 0x11, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72,
|
|
||||||
0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x66, 0x0a, 0x1b, 0x73,
|
|
||||||
0x65, 0x74, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x75, 0x73,
|
|
||||||
0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b,
|
|
||||||
0x32, 0x26, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
|
||||||
0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74,
|
|
||||||
0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x48, 0x00, 0x52, 0x17, 0x73, 0x65, 0x74, 0x4c,
|
|
||||||
0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65,
|
|
||||||
0x6c, 0x64, 0x73, 0x12, 0x3c, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68,
|
|
||||||
0x65, 0x72, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f,
|
|
||||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63,
|
|
||||||
0x68, 0x65, 0x72, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
|
||||||
0x72, 0x12, 0x45, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x76, 0x6f, 0x75, 0x63,
|
|
||||||
0x68, 0x65, 0x72, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x61, 0x72, 0x74,
|
|
||||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
|
|
||||||
0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76,
|
|
||||||
0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x6a, 0x0a, 0x1b, 0x75, 0x70, 0x73, 0x65,
|
|
||||||
0x72, 0x74, 0x5f, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
|
|
||||||
0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e,
|
|
||||||
0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70,
|
|
||||||
0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
|
||||||
0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x48, 0x00, 0x52, 0x19, 0x75, 0x70, 0x73, 0x65, 0x72,
|
|
||||||
0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74,
|
|
||||||
0x61, 0x69, 0x6c, 0x73, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x39, 0x5a, 0x37,
|
|
||||||
0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73,
|
|
||||||
0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70,
|
|
||||||
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x61, 0x72, 0x74, 0x3b, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d,
|
|
||||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -96,3 +96,24 @@ message IssueRefund {
|
|||||||
string return_id = 4; // optional linked RMA
|
string return_id = 4; // optional linked RMA
|
||||||
int64 at_ms = 5;
|
int64 at_ms = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestExchange opens an RMA return and reserves/adds replacement lines.
|
||||||
|
message RequestExchange {
|
||||||
|
string id = 1;
|
||||||
|
string return_id = 2;
|
||||||
|
string reason = 3;
|
||||||
|
repeated FulfillmentLine return_lines = 4;
|
||||||
|
repeated OrderLine new_lines = 5;
|
||||||
|
int64 at_ms = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrderDetails updates address/shipping price post-placement.
|
||||||
|
message EditOrderDetails {
|
||||||
|
bytes shipping_address = 1;
|
||||||
|
bytes billing_address = 2;
|
||||||
|
int64 shipping_price = 3;
|
||||||
|
int64 at_ms = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+195
-13
@@ -781,6 +781,160 @@ func (x *IssueRefund) GetAtMs() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestExchange opens an RMA return and reserves/adds replacement lines.
|
||||||
|
type RequestExchange struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
ReturnId string `protobuf:"bytes,2,opt,name=return_id,json=returnId,proto3" json:"return_id,omitempty"`
|
||||||
|
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||||
|
ReturnLines []*FulfillmentLine `protobuf:"bytes,4,rep,name=return_lines,json=returnLines,proto3" json:"return_lines,omitempty"`
|
||||||
|
NewLines []*OrderLine `protobuf:"bytes,5,rep,name=new_lines,json=newLines,proto3" json:"new_lines,omitempty"`
|
||||||
|
AtMs int64 `protobuf:"varint,6,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestExchange) Reset() {
|
||||||
|
*x = RequestExchange{}
|
||||||
|
mi := &file_order_proto_msgTypes[10]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestExchange) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*RequestExchange) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *RequestExchange) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[10]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use RequestExchange.ProtoReflect.Descriptor instead.
|
||||||
|
func (*RequestExchange) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{10}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestExchange) GetId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestExchange) GetReturnId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ReturnId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestExchange) GetReason() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reason
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestExchange) GetReturnLines() []*FulfillmentLine {
|
||||||
|
if x != nil {
|
||||||
|
return x.ReturnLines
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestExchange) GetNewLines() []*OrderLine {
|
||||||
|
if x != nil {
|
||||||
|
return x.NewLines
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RequestExchange) GetAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrderDetails updates address/shipping price post-placement.
|
||||||
|
type EditOrderDetails struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
ShippingAddress []byte `protobuf:"bytes,1,opt,name=shipping_address,json=shippingAddress,proto3" json:"shipping_address,omitempty"`
|
||||||
|
BillingAddress []byte `protobuf:"bytes,2,opt,name=billing_address,json=billingAddress,proto3" json:"billing_address,omitempty"`
|
||||||
|
ShippingPrice int64 `protobuf:"varint,3,opt,name=shipping_price,json=shippingPrice,proto3" json:"shipping_price,omitempty"`
|
||||||
|
AtMs int64 `protobuf:"varint,4,opt,name=at_ms,json=atMs,proto3" json:"at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EditOrderDetails) Reset() {
|
||||||
|
*x = EditOrderDetails{}
|
||||||
|
mi := &file_order_proto_msgTypes[11]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EditOrderDetails) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*EditOrderDetails) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *EditOrderDetails) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_order_proto_msgTypes[11]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use EditOrderDetails.ProtoReflect.Descriptor instead.
|
||||||
|
func (*EditOrderDetails) Descriptor() ([]byte, []int) {
|
||||||
|
return file_order_proto_rawDescGZIP(), []int{11}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EditOrderDetails) GetShippingAddress() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.ShippingAddress
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EditOrderDetails) GetBillingAddress() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.BillingAddress
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EditOrderDetails) GetShippingPrice() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ShippingPrice
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EditOrderDetails) GetAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
var File_order_proto protoreflect.FileDescriptor
|
var File_order_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
var file_order_proto_rawDesc = string([]byte{
|
var file_order_proto_rawDesc = string([]byte{
|
||||||
@@ -886,12 +1040,36 @@ var file_order_proto_rawDesc = string([]byte{
|
|||||||
0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65,
|
0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65,
|
||||||
0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72,
|
0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72,
|
||||||
0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73,
|
0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73,
|
||||||
0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39,
|
0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xe7, 0x01, 0x0a,
|
||||||
0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73,
|
0x0f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
|
||||||
0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70,
|
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
|
||||||
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72,
|
0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
|
||||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a,
|
||||||
0x33,
|
0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72,
|
||||||
|
0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x42, 0x0a, 0x0c, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f,
|
||||||
|
0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72,
|
||||||
|
0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c,
|
||||||
|
0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x0b, 0x72, 0x65,
|
||||||
|
0x74, 0x75, 0x72, 0x6e, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x36, 0x0a, 0x09, 0x6e, 0x65, 0x77,
|
||||||
|
0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f,
|
||||||
|
0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72,
|
||||||
|
0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x4c, 0x69, 0x6e, 0x65,
|
||||||
|
0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03,
|
||||||
|
0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xa2, 0x01, 0x0a, 0x10, 0x45, 0x64, 0x69, 0x74, 0x4f,
|
||||||
|
0x72, 0x64, 0x65, 0x72, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73,
|
||||||
|
0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18,
|
||||||
|
0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41,
|
||||||
|
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e,
|
||||||
|
0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52,
|
||||||
|
0x0e, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12,
|
||||||
|
0x25, 0x0a, 0x0e, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x72, 0x69, 0x63,
|
||||||
|
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e,
|
||||||
|
0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
|
||||||
|
0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39, 0x67,
|
||||||
|
0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f,
|
||||||
|
0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72,
|
||||||
|
0x6f, 0x74, 0x6f, 0x2f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f,
|
||||||
|
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -906,7 +1084,7 @@ func file_order_proto_rawDescGZIP() []byte {
|
|||||||
return file_order_proto_rawDescData
|
return file_order_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_order_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
var file_order_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
|
||||||
var file_order_proto_goTypes = []any{
|
var file_order_proto_goTypes = []any{
|
||||||
(*OrderLine)(nil), // 0: order_messages.OrderLine
|
(*OrderLine)(nil), // 0: order_messages.OrderLine
|
||||||
(*PlaceOrder)(nil), // 1: order_messages.PlaceOrder
|
(*PlaceOrder)(nil), // 1: order_messages.PlaceOrder
|
||||||
@@ -918,16 +1096,20 @@ var file_order_proto_goTypes = []any{
|
|||||||
(*CancelOrder)(nil), // 7: order_messages.CancelOrder
|
(*CancelOrder)(nil), // 7: order_messages.CancelOrder
|
||||||
(*RequestReturn)(nil), // 8: order_messages.RequestReturn
|
(*RequestReturn)(nil), // 8: order_messages.RequestReturn
|
||||||
(*IssueRefund)(nil), // 9: order_messages.IssueRefund
|
(*IssueRefund)(nil), // 9: order_messages.IssueRefund
|
||||||
|
(*RequestExchange)(nil), // 10: order_messages.RequestExchange
|
||||||
|
(*EditOrderDetails)(nil), // 11: order_messages.EditOrderDetails
|
||||||
}
|
}
|
||||||
var file_order_proto_depIdxs = []int32{
|
var file_order_proto_depIdxs = []int32{
|
||||||
0, // 0: order_messages.PlaceOrder.lines:type_name -> order_messages.OrderLine
|
0, // 0: order_messages.PlaceOrder.lines:type_name -> order_messages.OrderLine
|
||||||
4, // 1: order_messages.CreateFulfillment.lines:type_name -> order_messages.FulfillmentLine
|
4, // 1: order_messages.CreateFulfillment.lines:type_name -> order_messages.FulfillmentLine
|
||||||
4, // 2: order_messages.RequestReturn.lines:type_name -> order_messages.FulfillmentLine
|
4, // 2: order_messages.RequestReturn.lines:type_name -> order_messages.FulfillmentLine
|
||||||
3, // [3:3] is the sub-list for method output_type
|
4, // 3: order_messages.RequestExchange.return_lines:type_name -> order_messages.FulfillmentLine
|
||||||
3, // [3:3] is the sub-list for method input_type
|
0, // 4: order_messages.RequestExchange.new_lines:type_name -> order_messages.OrderLine
|
||||||
3, // [3:3] is the sub-list for extension type_name
|
5, // [5:5] is the sub-list for method output_type
|
||||||
3, // [3:3] is the sub-list for extension extendee
|
5, // [5:5] is the sub-list for method input_type
|
||||||
0, // [0:3] is the sub-list for field type_name
|
5, // [5:5] is the sub-list for extension type_name
|
||||||
|
5, // [5:5] is the sub-list for extension extendee
|
||||||
|
0, // [0:5] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_order_proto_init() }
|
func init() { file_order_proto_init() }
|
||||||
@@ -941,7 +1123,7 @@ func file_order_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_order_proto_rawDesc), len(file_order_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_order_proto_rawDesc), len(file_order_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 10,
|
NumMessages: 12,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 0,
|
NumServices: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ message LinkOrder {
|
|||||||
string status = 3; // order status snapshot
|
string status = 3; // order status snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AnonymizeProfile wipes all PII from the customer profile.
|
||||||
|
message AnonymizeProfile {}
|
||||||
|
|
||||||
message Mutation {
|
message Mutation {
|
||||||
oneof type {
|
oneof type {
|
||||||
SetProfile set_profile = 1;
|
SetProfile set_profile = 1;
|
||||||
@@ -86,5 +89,7 @@ message Mutation {
|
|||||||
LinkCart link_cart = 5;
|
LinkCart link_cart = 5;
|
||||||
LinkCheckout link_checkout = 6;
|
LinkCheckout link_checkout = 6;
|
||||||
LinkOrder link_order = 7;
|
LinkOrder link_order = 7;
|
||||||
|
AnonymizeProfile anonymize_profile = 8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+262
-200
@@ -2,7 +2,7 @@
|
|||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.5
|
// protoc-gen-go v1.36.5
|
||||||
// protoc v7.35.0
|
// protoc v7.35.0
|
||||||
// source: proto/profile.proto
|
// source: profile.proto
|
||||||
|
|
||||||
package profile_messages
|
package profile_messages
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ type Address struct {
|
|||||||
|
|
||||||
func (x *Address) Reset() {
|
func (x *Address) Reset() {
|
||||||
*x = Address{}
|
*x = Address{}
|
||||||
mi := &file_proto_profile_proto_msgTypes[0]
|
mi := &file_profile_proto_msgTypes[0]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ func (x *Address) String() string {
|
|||||||
func (*Address) ProtoMessage() {}
|
func (*Address) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Address) ProtoReflect() protoreflect.Message {
|
func (x *Address) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_profile_proto_msgTypes[0]
|
mi := &file_profile_proto_msgTypes[0]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -67,7 +67,7 @@ func (x *Address) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Address.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Address.ProtoReflect.Descriptor instead.
|
||||||
func (*Address) Descriptor() ([]byte, []int) {
|
func (*Address) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_profile_proto_rawDescGZIP(), []int{0}
|
return file_profile_proto_rawDescGZIP(), []int{0}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Address) GetId() uint32 {
|
func (x *Address) GetId() uint32 {
|
||||||
@@ -169,7 +169,7 @@ type SetProfile struct {
|
|||||||
|
|
||||||
func (x *SetProfile) Reset() {
|
func (x *SetProfile) Reset() {
|
||||||
*x = SetProfile{}
|
*x = SetProfile{}
|
||||||
mi := &file_proto_profile_proto_msgTypes[1]
|
mi := &file_profile_proto_msgTypes[1]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ func (x *SetProfile) String() string {
|
|||||||
func (*SetProfile) ProtoMessage() {}
|
func (*SetProfile) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *SetProfile) ProtoReflect() protoreflect.Message {
|
func (x *SetProfile) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_profile_proto_msgTypes[1]
|
mi := &file_profile_proto_msgTypes[1]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -194,7 +194,7 @@ func (x *SetProfile) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use SetProfile.ProtoReflect.Descriptor instead.
|
// Deprecated: Use SetProfile.ProtoReflect.Descriptor instead.
|
||||||
func (*SetProfile) Descriptor() ([]byte, []int) {
|
func (*SetProfile) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_profile_proto_rawDescGZIP(), []int{1}
|
return file_profile_proto_rawDescGZIP(), []int{1}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *SetProfile) GetName() string {
|
func (x *SetProfile) GetName() string {
|
||||||
@@ -249,7 +249,7 @@ type AddAddress struct {
|
|||||||
|
|
||||||
func (x *AddAddress) Reset() {
|
func (x *AddAddress) Reset() {
|
||||||
*x = AddAddress{}
|
*x = AddAddress{}
|
||||||
mi := &file_proto_profile_proto_msgTypes[2]
|
mi := &file_profile_proto_msgTypes[2]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ func (x *AddAddress) String() string {
|
|||||||
func (*AddAddress) ProtoMessage() {}
|
func (*AddAddress) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *AddAddress) ProtoReflect() protoreflect.Message {
|
func (x *AddAddress) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_profile_proto_msgTypes[2]
|
mi := &file_profile_proto_msgTypes[2]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -274,7 +274,7 @@ func (x *AddAddress) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use AddAddress.ProtoReflect.Descriptor instead.
|
// Deprecated: Use AddAddress.ProtoReflect.Descriptor instead.
|
||||||
func (*AddAddress) Descriptor() ([]byte, []int) {
|
func (*AddAddress) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_profile_proto_rawDescGZIP(), []int{2}
|
return file_profile_proto_rawDescGZIP(), []int{2}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *AddAddress) GetAddress() *Address {
|
func (x *AddAddress) GetAddress() *Address {
|
||||||
@@ -305,7 +305,7 @@ type UpdateAddress struct {
|
|||||||
|
|
||||||
func (x *UpdateAddress) Reset() {
|
func (x *UpdateAddress) Reset() {
|
||||||
*x = UpdateAddress{}
|
*x = UpdateAddress{}
|
||||||
mi := &file_proto_profile_proto_msgTypes[3]
|
mi := &file_profile_proto_msgTypes[3]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ func (x *UpdateAddress) String() string {
|
|||||||
func (*UpdateAddress) ProtoMessage() {}
|
func (*UpdateAddress) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *UpdateAddress) ProtoReflect() protoreflect.Message {
|
func (x *UpdateAddress) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_profile_proto_msgTypes[3]
|
mi := &file_profile_proto_msgTypes[3]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -330,7 +330,7 @@ func (x *UpdateAddress) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use UpdateAddress.ProtoReflect.Descriptor instead.
|
// Deprecated: Use UpdateAddress.ProtoReflect.Descriptor instead.
|
||||||
func (*UpdateAddress) Descriptor() ([]byte, []int) {
|
func (*UpdateAddress) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_profile_proto_rawDescGZIP(), []int{3}
|
return file_profile_proto_rawDescGZIP(), []int{3}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *UpdateAddress) GetId() uint32 {
|
func (x *UpdateAddress) GetId() uint32 {
|
||||||
@@ -427,7 +427,7 @@ type RemoveAddress struct {
|
|||||||
|
|
||||||
func (x *RemoveAddress) Reset() {
|
func (x *RemoveAddress) Reset() {
|
||||||
*x = RemoveAddress{}
|
*x = RemoveAddress{}
|
||||||
mi := &file_proto_profile_proto_msgTypes[4]
|
mi := &file_profile_proto_msgTypes[4]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -439,7 +439,7 @@ func (x *RemoveAddress) String() string {
|
|||||||
func (*RemoveAddress) ProtoMessage() {}
|
func (*RemoveAddress) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *RemoveAddress) ProtoReflect() protoreflect.Message {
|
func (x *RemoveAddress) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_profile_proto_msgTypes[4]
|
mi := &file_profile_proto_msgTypes[4]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -452,7 +452,7 @@ func (x *RemoveAddress) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use RemoveAddress.ProtoReflect.Descriptor instead.
|
// Deprecated: Use RemoveAddress.ProtoReflect.Descriptor instead.
|
||||||
func (*RemoveAddress) Descriptor() ([]byte, []int) {
|
func (*RemoveAddress) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_profile_proto_rawDescGZIP(), []int{4}
|
return file_profile_proto_rawDescGZIP(), []int{4}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *RemoveAddress) GetId() uint32 {
|
func (x *RemoveAddress) GetId() uint32 {
|
||||||
@@ -473,7 +473,7 @@ type LinkCart struct {
|
|||||||
|
|
||||||
func (x *LinkCart) Reset() {
|
func (x *LinkCart) Reset() {
|
||||||
*x = LinkCart{}
|
*x = LinkCart{}
|
||||||
mi := &file_proto_profile_proto_msgTypes[5]
|
mi := &file_profile_proto_msgTypes[5]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -485,7 +485,7 @@ func (x *LinkCart) String() string {
|
|||||||
func (*LinkCart) ProtoMessage() {}
|
func (*LinkCart) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *LinkCart) ProtoReflect() protoreflect.Message {
|
func (x *LinkCart) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_profile_proto_msgTypes[5]
|
mi := &file_profile_proto_msgTypes[5]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -498,7 +498,7 @@ func (x *LinkCart) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use LinkCart.ProtoReflect.Descriptor instead.
|
// Deprecated: Use LinkCart.ProtoReflect.Descriptor instead.
|
||||||
func (*LinkCart) Descriptor() ([]byte, []int) {
|
func (*LinkCart) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_profile_proto_rawDescGZIP(), []int{5}
|
return file_profile_proto_rawDescGZIP(), []int{5}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *LinkCart) GetCartId() uint64 {
|
func (x *LinkCart) GetCartId() uint64 {
|
||||||
@@ -526,7 +526,7 @@ type LinkCheckout struct {
|
|||||||
|
|
||||||
func (x *LinkCheckout) Reset() {
|
func (x *LinkCheckout) Reset() {
|
||||||
*x = LinkCheckout{}
|
*x = LinkCheckout{}
|
||||||
mi := &file_proto_profile_proto_msgTypes[6]
|
mi := &file_profile_proto_msgTypes[6]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -538,7 +538,7 @@ func (x *LinkCheckout) String() string {
|
|||||||
func (*LinkCheckout) ProtoMessage() {}
|
func (*LinkCheckout) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *LinkCheckout) ProtoReflect() protoreflect.Message {
|
func (x *LinkCheckout) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_profile_proto_msgTypes[6]
|
mi := &file_profile_proto_msgTypes[6]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -551,7 +551,7 @@ func (x *LinkCheckout) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use LinkCheckout.ProtoReflect.Descriptor instead.
|
// Deprecated: Use LinkCheckout.ProtoReflect.Descriptor instead.
|
||||||
func (*LinkCheckout) Descriptor() ([]byte, []int) {
|
func (*LinkCheckout) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_profile_proto_rawDescGZIP(), []int{6}
|
return file_profile_proto_rawDescGZIP(), []int{6}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *LinkCheckout) GetCheckoutId() uint64 {
|
func (x *LinkCheckout) GetCheckoutId() uint64 {
|
||||||
@@ -580,7 +580,7 @@ type LinkOrder struct {
|
|||||||
|
|
||||||
func (x *LinkOrder) Reset() {
|
func (x *LinkOrder) Reset() {
|
||||||
*x = LinkOrder{}
|
*x = LinkOrder{}
|
||||||
mi := &file_proto_profile_proto_msgTypes[7]
|
mi := &file_profile_proto_msgTypes[7]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -592,7 +592,7 @@ func (x *LinkOrder) String() string {
|
|||||||
func (*LinkOrder) ProtoMessage() {}
|
func (*LinkOrder) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *LinkOrder) ProtoReflect() protoreflect.Message {
|
func (x *LinkOrder) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_profile_proto_msgTypes[7]
|
mi := &file_profile_proto_msgTypes[7]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -605,7 +605,7 @@ func (x *LinkOrder) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use LinkOrder.ProtoReflect.Descriptor instead.
|
// Deprecated: Use LinkOrder.ProtoReflect.Descriptor instead.
|
||||||
func (*LinkOrder) Descriptor() ([]byte, []int) {
|
func (*LinkOrder) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_profile_proto_rawDescGZIP(), []int{7}
|
return file_profile_proto_rawDescGZIP(), []int{7}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *LinkOrder) GetOrderReference() string {
|
func (x *LinkOrder) GetOrderReference() string {
|
||||||
@@ -629,6 +629,43 @@ func (x *LinkOrder) GetStatus() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AnonymizeProfile wipes all PII from the customer profile.
|
||||||
|
type AnonymizeProfile struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AnonymizeProfile) Reset() {
|
||||||
|
*x = AnonymizeProfile{}
|
||||||
|
mi := &file_profile_proto_msgTypes[8]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AnonymizeProfile) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AnonymizeProfile) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *AnonymizeProfile) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_profile_proto_msgTypes[8]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use AnonymizeProfile.ProtoReflect.Descriptor instead.
|
||||||
|
func (*AnonymizeProfile) Descriptor() ([]byte, []int) {
|
||||||
|
return file_profile_proto_rawDescGZIP(), []int{8}
|
||||||
|
}
|
||||||
|
|
||||||
type Mutation struct {
|
type Mutation struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// Types that are valid to be assigned to Type:
|
// Types that are valid to be assigned to Type:
|
||||||
@@ -640,6 +677,7 @@ type Mutation struct {
|
|||||||
// *Mutation_LinkCart
|
// *Mutation_LinkCart
|
||||||
// *Mutation_LinkCheckout
|
// *Mutation_LinkCheckout
|
||||||
// *Mutation_LinkOrder
|
// *Mutation_LinkOrder
|
||||||
|
// *Mutation_AnonymizeProfile
|
||||||
Type isMutation_Type `protobuf_oneof:"type"`
|
Type isMutation_Type `protobuf_oneof:"type"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -647,7 +685,7 @@ type Mutation struct {
|
|||||||
|
|
||||||
func (x *Mutation) Reset() {
|
func (x *Mutation) Reset() {
|
||||||
*x = Mutation{}
|
*x = Mutation{}
|
||||||
mi := &file_proto_profile_proto_msgTypes[8]
|
mi := &file_profile_proto_msgTypes[9]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -659,7 +697,7 @@ func (x *Mutation) String() string {
|
|||||||
func (*Mutation) ProtoMessage() {}
|
func (*Mutation) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Mutation) ProtoReflect() protoreflect.Message {
|
func (x *Mutation) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_profile_proto_msgTypes[8]
|
mi := &file_profile_proto_msgTypes[9]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -672,7 +710,7 @@ func (x *Mutation) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Mutation.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Mutation.ProtoReflect.Descriptor instead.
|
||||||
func (*Mutation) Descriptor() ([]byte, []int) {
|
func (*Mutation) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_profile_proto_rawDescGZIP(), []int{8}
|
return file_profile_proto_rawDescGZIP(), []int{9}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Mutation) GetType() isMutation_Type {
|
func (x *Mutation) GetType() isMutation_Type {
|
||||||
@@ -745,6 +783,15 @@ func (x *Mutation) GetLinkOrder() *LinkOrder {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *Mutation) GetAnonymizeProfile() *AnonymizeProfile {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Type.(*Mutation_AnonymizeProfile); ok {
|
||||||
|
return x.AnonymizeProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type isMutation_Type interface {
|
type isMutation_Type interface {
|
||||||
isMutation_Type()
|
isMutation_Type()
|
||||||
}
|
}
|
||||||
@@ -777,6 +824,10 @@ type Mutation_LinkOrder struct {
|
|||||||
LinkOrder *LinkOrder `protobuf:"bytes,7,opt,name=link_order,json=linkOrder,proto3,oneof"`
|
LinkOrder *LinkOrder `protobuf:"bytes,7,opt,name=link_order,json=linkOrder,proto3,oneof"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Mutation_AnonymizeProfile struct {
|
||||||
|
AnonymizeProfile *AnonymizeProfile `protobuf:"bytes,8,opt,name=anonymize_profile,json=anonymizeProfile,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
func (*Mutation_SetProfile) isMutation_Type() {}
|
func (*Mutation_SetProfile) isMutation_Type() {}
|
||||||
|
|
||||||
func (*Mutation_AddAddress) isMutation_Type() {}
|
func (*Mutation_AddAddress) isMutation_Type() {}
|
||||||
@@ -791,170 +842,179 @@ func (*Mutation_LinkCheckout) isMutation_Type() {}
|
|||||||
|
|
||||||
func (*Mutation_LinkOrder) isMutation_Type() {}
|
func (*Mutation_LinkOrder) isMutation_Type() {}
|
||||||
|
|
||||||
var File_proto_profile_proto protoreflect.FileDescriptor
|
func (*Mutation_AnonymizeProfile) isMutation_Type() {}
|
||||||
|
|
||||||
var file_proto_profile_proto_rawDesc = string([]byte{
|
var File_profile_proto protoreflect.FileDescriptor
|
||||||
0x0a, 0x13, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x2e,
|
|
||||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d,
|
var file_profile_proto_rawDesc = string([]byte{
|
||||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xfe, 0x02, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72,
|
0x0a, 0x0d, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12,
|
||||||
0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52,
|
0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||||
0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01,
|
0x73, 0x22, 0xfe, 0x02, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a,
|
||||||
0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x75, 0x6c,
|
0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a,
|
||||||
0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x75, 0x6c,
|
0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61,
|
||||||
0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
0x62, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18,
|
||||||
0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x64,
|
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12,
|
||||||
0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64,
|
0x22, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18,
|
||||||
0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48,
|
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69,
|
||||||
0x00, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88,
|
0x6e, 0x65, 0x31, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69,
|
||||||
0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
|
0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x61, 0x64, 0x64,
|
||||||
0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18,
|
0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04,
|
||||||
0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03,
|
0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79,
|
||||||
0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x12, 0x18,
|
0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52,
|
0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20,
|
||||||
0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e,
|
0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
|
||||||
0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65,
|
0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||||
0x88, 0x01, 0x01, 0x12, 0x2c, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
|
0x72, 0x79, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28,
|
||||||
0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11,
|
0x09, 0x48, 0x01, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x2c, 0x0a,
|
||||||
0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e,
|
0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69,
|
||||||
0x67, 0x12, 0x2a, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69,
|
0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61,
|
||||||
0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44,
|
0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x2a, 0x0a, 0x10, 0x69,
|
||||||
0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x42, 0x0f, 0x0a,
|
|
||||||
0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x08,
|
|
||||||
0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x22, 0x85, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x74,
|
|
||||||
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
|
|
||||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01,
|
|
||||||
0x12, 0x19, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48,
|
|
||||||
0x01, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70,
|
|
||||||
0x68, 0x6f, 0x6e, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x05, 0x70, 0x68,
|
|
||||||
0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61,
|
|
||||||
0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67,
|
|
||||||
0x75, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65,
|
|
||||||
0x6e, 0x63, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x08, 0x63, 0x75, 0x72,
|
|
||||||
0x72, 0x65, 0x6e, 0x63, 0x79, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x74,
|
|
||||||
0x61, 0x72, 0x55, 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x09, 0x61,
|
|
||||||
0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f,
|
|
||||||
0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08,
|
|
||||||
0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x6e,
|
|
||||||
0x67, 0x75, 0x61, 0x67, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
|
|
||||||
0x63, 0x79, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c,
|
|
||||||
0x22, 0x41, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x33,
|
|
||||||
0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
|
||||||
0x19, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
|
||||||
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72,
|
|
||||||
0x65, 0x73, 0x73, 0x22, 0xab, 0x04, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64,
|
|
||||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
|
||||||
0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02,
|
|
||||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x88, 0x01, 0x01,
|
|
||||||
0x12, 0x1f, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01,
|
|
||||||
0x28, 0x09, 0x48, 0x01, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01,
|
|
||||||
0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65,
|
|
||||||
0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65,
|
|
||||||
0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64,
|
|
||||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
|
|
||||||
0x48, 0x03, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32,
|
|
||||||
0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28,
|
|
||||||
0x09, 0x48, 0x04, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05,
|
|
||||||
0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x05, 0x73,
|
|
||||||
0x74, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08,
|
|
||||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d,
|
|
||||||
0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48,
|
|
||||||
0x07, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a,
|
|
||||||
0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x08, 0x52, 0x05,
|
|
||||||
0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65,
|
|
||||||
0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20,
|
|
||||||
0x01, 0x28, 0x08, 0x48, 0x09, 0x52, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
|
|
||||||
0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x69,
|
|
||||||
0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18,
|
0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18,
|
||||||
0x0c, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0a, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75,
|
0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
|
||||||
0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06,
|
0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72,
|
||||||
0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x4e,
|
0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f,
|
||||||
0x61, 0x6d, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c,
|
0x6e, 0x65, 0x22, 0x85, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c,
|
||||||
0x69, 0x6e, 0x65, 0x31, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||||
0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x08,
|
0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x65, 0x6d,
|
||||||
0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70,
|
0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x65, 0x6d, 0x61,
|
||||||
0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x08, 0x0a, 0x06,
|
0x69, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x03,
|
||||||
0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66,
|
0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01,
|
||||||
0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x42, 0x13, 0x0a, 0x11,
|
0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01,
|
||||||
0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e,
|
0x28, 0x09, 0x48, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x88, 0x01,
|
||||||
0x67, 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65,
|
0x01, 0x12, 0x1f, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x05, 0x20,
|
||||||
0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02,
|
0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x88,
|
||||||
0x69, 0x64, 0x22, 0x38, 0x0a, 0x08, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x16,
|
0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x18,
|
||||||
0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06,
|
0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55,
|
||||||
0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18,
|
0x72, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08,
|
||||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x46, 0x0a, 0x0c,
|
0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f,
|
||||||
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x1e, 0x0a, 0x0a,
|
0x6e, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x42,
|
||||||
0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04,
|
0x0b, 0x0a, 0x09, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x0c, 0x0a, 0x0a,
|
||||||
0x52, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06,
|
0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x22, 0x41, 0x0a, 0x0a, 0x41, 0x64,
|
||||||
0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61,
|
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x33, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72,
|
||||||
0x72, 0x74, 0x49, 0x64, 0x22, 0x63, 0x0a, 0x09, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65,
|
0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x66,
|
||||||
0x72, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65,
|
0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64,
|
||||||
0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72,
|
0x72, 0x65, 0x73, 0x73, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0xab, 0x04,
|
||||||
0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72,
|
0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12,
|
||||||
0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49,
|
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12,
|
||||||
0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28,
|
0x19, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
|
||||||
0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xe8, 0x03, 0x0a, 0x08, 0x4d, 0x75,
|
0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x66, 0x75,
|
||||||
0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3f, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x72,
|
0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x08,
|
||||||
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72,
|
0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61,
|
||||||
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53,
|
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||||
0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x74,
|
0x09, 0x48, 0x02, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65,
|
||||||
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x3f, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x61,
|
0x31, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c,
|
||||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70,
|
0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x0c, 0x61, 0x64,
|
||||||
0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a,
|
||||||
0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64,
|
0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x04, 0x63,
|
||||||
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x75, 0x70, 0x64, 0x61,
|
0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18,
|
||||||
0x74, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
|
0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x88, 0x01,
|
||||||
0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06,
|
||||||
0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
|
0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
|
||||||
0x73, 0x48, 0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65,
|
0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48, 0x07, 0x52, 0x07, 0x63, 0x6f, 0x75,
|
||||||
0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64,
|
0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65,
|
||||||
0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f,
|
0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x08, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88,
|
||||||
0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65,
|
0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53,
|
||||||
0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72,
|
0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x48, 0x09, 0x52,
|
||||||
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09,
|
0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69,
|
||||||
0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75,
|
||||||
0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48,
|
||||||
0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c,
|
0x0a, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c,
|
||||||
0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x45, 0x0a, 0x0d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f,
|
0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c,
|
||||||
0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
|
0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x0f, 0x0a,
|
||||||
|
0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x42, 0x0f,
|
||||||
|
0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42,
|
||||||
|
0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61,
|
||||||
|
0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63,
|
||||||
|
0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65,
|
||||||
|
0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68,
|
||||||
|
0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66,
|
||||||
|
0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x22, 0x1f, 0x0a, 0x0d, 0x52,
|
||||||
|
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02,
|
||||||
|
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0x38, 0x0a, 0x08,
|
||||||
|
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74,
|
||||||
|
0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64,
|
||||||
|
0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
|
0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x46, 0x0a, 0x0c, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68,
|
||||||
|
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f,
|
||||||
|
0x75, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x63, 0x68, 0x65, 0x63,
|
||||||
|
0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64,
|
||||||
|
0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x22, 0x63,
|
||||||
|
0x0a, 0x09, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x6f,
|
||||||
|
0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20,
|
||||||
|
0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65,
|
||||||
|
0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20,
|
||||||
|
0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73,
|
||||||
|
0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61,
|
||||||
|
0x74, 0x75, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65,
|
||||||
|
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x22, 0xbb, 0x04, 0x0a, 0x08, 0x4d, 0x75, 0x74, 0x61,
|
||||||
|
0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3f, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x66,
|
||||||
|
0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x66,
|
||||||
|
0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
|
||||||
|
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x74, 0x50, 0x72,
|
||||||
|
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x3f, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x61, 0x64, 0x64,
|
||||||
|
0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f,
|
||||||
|
0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64,
|
||||||
|
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x64, 0x41,
|
||||||
|
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65,
|
||||||
|
0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f,
|
||||||
0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||||
0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x48, 0x00,
|
0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48,
|
||||||
0x52, 0x0c, 0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x3c,
|
0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
||||||
0x0a, 0x0a, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01,
|
0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65,
|
||||||
0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73,
|
0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69,
|
||||||
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x48,
|
0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f,
|
||||||
0x00, 0x52, 0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x06, 0x0a, 0x04,
|
0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d,
|
||||||
0x74, 0x79, 0x70, 0x65, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e,
|
0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09, 0x6c, 0x69,
|
||||||
0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74,
|
0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
|
||||||
0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f,
|
0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
||||||
0x66, 0x69, 0x6c, 0x65, 0x3b, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73,
|
0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x69, 0x6e,
|
||||||
0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x45, 0x0a, 0x0d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x68,
|
||||||
|
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70,
|
||||||
|
0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||||
|
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x48, 0x00, 0x52, 0x0c,
|
||||||
|
0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x3c, 0x0a, 0x0a,
|
||||||
|
0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b,
|
||||||
|
0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||||
|
0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x48, 0x00, 0x52,
|
||||||
|
0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x51, 0x0a, 0x11, 0x61, 0x6e,
|
||||||
|
0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18,
|
||||||
|
0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f,
|
||||||
|
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69,
|
||||||
|
0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x10, 0x61, 0x6e, 0x6f,
|
||||||
|
0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x42, 0x06, 0x0a,
|
||||||
|
0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e,
|
||||||
|
0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72,
|
||||||
|
0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72,
|
||||||
|
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x3b, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_proto_profile_proto_rawDescOnce sync.Once
|
file_profile_proto_rawDescOnce sync.Once
|
||||||
file_proto_profile_proto_rawDescData []byte
|
file_profile_proto_rawDescData []byte
|
||||||
)
|
)
|
||||||
|
|
||||||
func file_proto_profile_proto_rawDescGZIP() []byte {
|
func file_profile_proto_rawDescGZIP() []byte {
|
||||||
file_proto_profile_proto_rawDescOnce.Do(func() {
|
file_profile_proto_rawDescOnce.Do(func() {
|
||||||
file_proto_profile_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_profile_proto_rawDesc), len(file_proto_profile_proto_rawDesc)))
|
file_profile_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_profile_proto_rawDesc), len(file_profile_proto_rawDesc)))
|
||||||
})
|
})
|
||||||
return file_proto_profile_proto_rawDescData
|
return file_profile_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_proto_profile_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
var file_profile_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
||||||
var file_proto_profile_proto_goTypes = []any{
|
var file_profile_proto_goTypes = []any{
|
||||||
(*Address)(nil), // 0: profile_messages.Address
|
(*Address)(nil), // 0: profile_messages.Address
|
||||||
(*SetProfile)(nil), // 1: profile_messages.SetProfile
|
(*SetProfile)(nil), // 1: profile_messages.SetProfile
|
||||||
(*AddAddress)(nil), // 2: profile_messages.AddAddress
|
(*AddAddress)(nil), // 2: profile_messages.AddAddress
|
||||||
(*UpdateAddress)(nil), // 3: profile_messages.UpdateAddress
|
(*UpdateAddress)(nil), // 3: profile_messages.UpdateAddress
|
||||||
(*RemoveAddress)(nil), // 4: profile_messages.RemoveAddress
|
(*RemoveAddress)(nil), // 4: profile_messages.RemoveAddress
|
||||||
(*LinkCart)(nil), // 5: profile_messages.LinkCart
|
(*LinkCart)(nil), // 5: profile_messages.LinkCart
|
||||||
(*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout
|
(*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout
|
||||||
(*LinkOrder)(nil), // 7: profile_messages.LinkOrder
|
(*LinkOrder)(nil), // 7: profile_messages.LinkOrder
|
||||||
(*Mutation)(nil), // 8: profile_messages.Mutation
|
(*AnonymizeProfile)(nil), // 8: profile_messages.AnonymizeProfile
|
||||||
|
(*Mutation)(nil), // 9: profile_messages.Mutation
|
||||||
}
|
}
|
||||||
var file_proto_profile_proto_depIdxs = []int32{
|
var file_profile_proto_depIdxs = []int32{
|
||||||
0, // 0: profile_messages.AddAddress.address:type_name -> profile_messages.Address
|
0, // 0: profile_messages.AddAddress.address:type_name -> profile_messages.Address
|
||||||
1, // 1: profile_messages.Mutation.set_profile:type_name -> profile_messages.SetProfile
|
1, // 1: profile_messages.Mutation.set_profile:type_name -> profile_messages.SetProfile
|
||||||
2, // 2: profile_messages.Mutation.add_address:type_name -> profile_messages.AddAddress
|
2, // 2: profile_messages.Mutation.add_address:type_name -> profile_messages.AddAddress
|
||||||
@@ -963,22 +1023,23 @@ var file_proto_profile_proto_depIdxs = []int32{
|
|||||||
5, // 5: profile_messages.Mutation.link_cart:type_name -> profile_messages.LinkCart
|
5, // 5: profile_messages.Mutation.link_cart:type_name -> profile_messages.LinkCart
|
||||||
6, // 6: profile_messages.Mutation.link_checkout:type_name -> profile_messages.LinkCheckout
|
6, // 6: profile_messages.Mutation.link_checkout:type_name -> profile_messages.LinkCheckout
|
||||||
7, // 7: profile_messages.Mutation.link_order:type_name -> profile_messages.LinkOrder
|
7, // 7: profile_messages.Mutation.link_order:type_name -> profile_messages.LinkOrder
|
||||||
8, // [8:8] is the sub-list for method output_type
|
8, // 8: profile_messages.Mutation.anonymize_profile:type_name -> profile_messages.AnonymizeProfile
|
||||||
8, // [8:8] is the sub-list for method input_type
|
9, // [9:9] is the sub-list for method output_type
|
||||||
8, // [8:8] is the sub-list for extension type_name
|
9, // [9:9] is the sub-list for method input_type
|
||||||
8, // [8:8] is the sub-list for extension extendee
|
9, // [9:9] is the sub-list for extension type_name
|
||||||
0, // [0:8] is the sub-list for field type_name
|
9, // [9:9] is the sub-list for extension extendee
|
||||||
|
0, // [0:9] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_proto_profile_proto_init() }
|
func init() { file_profile_proto_init() }
|
||||||
func file_proto_profile_proto_init() {
|
func file_profile_proto_init() {
|
||||||
if File_proto_profile_proto != nil {
|
if File_profile_proto != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file_proto_profile_proto_msgTypes[0].OneofWrappers = []any{}
|
file_profile_proto_msgTypes[0].OneofWrappers = []any{}
|
||||||
file_proto_profile_proto_msgTypes[1].OneofWrappers = []any{}
|
file_profile_proto_msgTypes[1].OneofWrappers = []any{}
|
||||||
file_proto_profile_proto_msgTypes[3].OneofWrappers = []any{}
|
file_profile_proto_msgTypes[3].OneofWrappers = []any{}
|
||||||
file_proto_profile_proto_msgTypes[8].OneofWrappers = []any{
|
file_profile_proto_msgTypes[9].OneofWrappers = []any{
|
||||||
(*Mutation_SetProfile)(nil),
|
(*Mutation_SetProfile)(nil),
|
||||||
(*Mutation_AddAddress)(nil),
|
(*Mutation_AddAddress)(nil),
|
||||||
(*Mutation_UpdateAddress)(nil),
|
(*Mutation_UpdateAddress)(nil),
|
||||||
@@ -986,22 +1047,23 @@ func file_proto_profile_proto_init() {
|
|||||||
(*Mutation_LinkCart)(nil),
|
(*Mutation_LinkCart)(nil),
|
||||||
(*Mutation_LinkCheckout)(nil),
|
(*Mutation_LinkCheckout)(nil),
|
||||||
(*Mutation_LinkOrder)(nil),
|
(*Mutation_LinkOrder)(nil),
|
||||||
|
(*Mutation_AnonymizeProfile)(nil),
|
||||||
}
|
}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_profile_proto_rawDesc), len(file_proto_profile_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_profile_proto_rawDesc), len(file_profile_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 9,
|
NumMessages: 10,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 0,
|
NumServices: 0,
|
||||||
},
|
},
|
||||||
GoTypes: file_proto_profile_proto_goTypes,
|
GoTypes: file_profile_proto_goTypes,
|
||||||
DependencyIndexes: file_proto_profile_proto_depIdxs,
|
DependencyIndexes: file_profile_proto_depIdxs,
|
||||||
MessageInfos: file_proto_profile_proto_msgTypes,
|
MessageInfos: file_profile_proto_msgTypes,
|
||||||
}.Build()
|
}.Build()
|
||||||
File_proto_profile_proto = out.File
|
File_profile_proto = out.File
|
||||||
file_proto_profile_proto_goTypes = nil
|
file_profile_proto_goTypes = nil
|
||||||
file_proto_profile_proto_depIdxs = nil
|
file_profile_proto_depIdxs = nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user