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

This commit is contained in:
2026-06-27 19:49:00 +02:00
parent 492f54ff45
commit 528c59bfd3
67 changed files with 3618 additions and 1031 deletions
-7
View File
@@ -9,7 +9,6 @@ import (
"time"
messages "git.k6n.net/mats/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
@@ -31,7 +30,6 @@ const name = "grpc_server"
var (
tracer = otel.Tracer(name)
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
pingCalls metric.Int64Counter
negotiateCalls metric.Int64Counter
getLocalActorIdsCalls metric.Int64Counter
@@ -88,7 +86,6 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
attribute.String("host", req.Host),
attribute.Int("id_count", len(req.Ids)),
)
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
@@ -139,7 +136,6 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
attribute.String("host", req.Host),
attribute.Int("id_count", len(req.Ids)),
)
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
@@ -215,7 +211,6 @@ func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.Negotiat
attribute.String("component", "controlplane"),
attribute.Int("known_hosts_count", len(req.KnownHosts)),
)
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
negotiateCalls.Add(ctx, 1)
s.pool.Negotiate(req.KnownHosts)
@@ -231,7 +226,6 @@ func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.E
attribute.String("component", "controlplane"),
attribute.Int("id_count", len(ids)),
)
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
getLocalActorIdsCalls.Add(ctx, 1)
return &messages.ActorIdsReply{Ids: ids}, nil
@@ -246,7 +240,6 @@ func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNot
attribute.String("component", "controlplane"),
attribute.String("host", host),
)
logger.InfoContext(ctx, "closing notice", "host", host)
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
if host != "" {
+13 -6
View File
@@ -54,6 +54,8 @@ type CartItem struct {
OrderReference string `json:"orderReference,omitempty"`
IsSubscribed bool `json:"isSubscribed,omitempty"`
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
InventoryTracked bool `json:"inventoryTracked,omitempty"`
DropShip bool `json:"dropShip,omitempty"`
// Extra holds arbitrary dynamic product data. Its keys are flattened onto
// the item object in JSON (see cart_item_json.go), so they are returned
@@ -152,12 +154,13 @@ type CartGrain struct {
}
type Voucher struct {
Code string `json:"code"`
Applied bool `json:"applied"`
Rules []string `json:"rules"`
Description string `json:"description,omitempty"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
Code string `json:"code"`
Applied bool `json:"applied"`
Rules []string `json:"rules"`
Description string `json:"description,omitempty"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
BypassedByPromotions bool `json:"-"`
}
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
@@ -298,6 +301,10 @@ func (c *CartGrain) UpdateTotals() {
_, ok := voucher.AppliesTo(c)
voucher.Applied = false
if ok {
if voucher.BypassedByPromotions {
voucher.Applied = true
continue
}
value := NewPriceFromIncVat(voucher.Value, 25)
if c.TotalPrice.IncVat <= value.IncVat {
// don't apply discounts to more than the total price
+6
View File
@@ -51,6 +51,12 @@ func (c *CartMutationContext) UseReservations(item *CartItem) bool {
if item.ReservationEndTime != nil {
return true
}
if item.DropShip {
return false
}
if item.InventoryTracked {
return true
}
return item.Cgm == "55010"
}
+4
View File
@@ -76,6 +76,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
}
existing.Quantity += uint16(m.Quantity)
existing.Stock = uint16(m.Stock)
existing.InventoryTracked = m.InventoryTracked
existing.DropShip = m.DropShip
// If existing had nil store but new has one, adopt it.
if existing.StoreId == nil && m.StoreId != nil {
existing.StoreId = m.StoreId
@@ -144,6 +146,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
Extra: decodeExtra(m.ExtraJson),
CustomFields: m.CustomFields,
InventoryTracked: m.InventoryTracked,
DropShip: m.DropShip,
}
if needsReservation && c.UseReservations(cartItem) {
+1 -1
View File
@@ -50,7 +50,7 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
if m.Quantity <= 0 {
// Remove the item
itemToRemove := g.Items[foundIndex]
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.Before(time.Now()) {
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.After(time.Now()) {
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
if err != nil {
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
+58
View File
@@ -0,0 +1,58 @@
package cart
// TaxProvider computes taxes for orders and line items.
// It mirrors the PaymentProvider and ShippingProvider seam patterns: one
// interface, interchangeable implementations.
//
// All tax rates are expressed as raw percent (e.g. 25 = 25 %). This matches the
// OrderLine.tax_rate proto convention.
type TaxProvider interface {
// Name returns the provider name for identification (logging, metrics).
Name() string
// ComputeTax computes the tax portion from a gross (inc-VAT) total and tax
// rate. Both total and return value are in minor currency units (ore).
// taxRate is expressed as raw percent (e.g. 25 = 25 %).
//
// Formula: tax = total x rate / (100 + rate)
//
// When taxRate <= 0 the result is 0 (untaxed / zero-rated items).
ComputeTax(total int64, taxRate int) int64
// DefaultTaxRate returns the default VAT rate for a country, expressed as
// raw percent (e.g. 25 = 25 %). Used for deliveries and items where no
// explicit rate was recorded at add-to-cart time.
//
// Return 0 if the country is unknown or untaxed.
DefaultTaxRate(country string) int
}
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
// taxRate is raw percent (e.g. 25 = 25 %). When taxRate <= 0 the result is 0.
//
// This differs from GetTaxAmount which uses the CartItem convention
// (percent x 100, e.g. 2500 = 25.00 %) with formula total x rate / (10000 + rate).
func ComputeTax(total int64, taxRate int) int64 {
if taxRate <= 0 {
return 0
}
return total * int64(taxRate) / int64(100+taxRate)
}
// StaticTaxProvider is a stateless, always-available TaxProvider that simply
// runs the formula without any country-specific rates. Use for local dev,
// tests, and as a no-dependency default.
type StaticTaxProvider struct{}
var _ TaxProvider = (*StaticTaxProvider)(nil)
func NewStaticTaxProvider() *StaticTaxProvider { return &StaticTaxProvider{} }
func (p *StaticTaxProvider) Name() string { return "static" }
func (p *StaticTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// DefaultTaxRate returns 0 for any country (no awareness).
func (p *StaticTaxProvider) DefaultTaxRate(_ string) int { return 0 }
+55
View File
@@ -0,0 +1,55 @@
package cart
// NordicTaxProvider implements TaxProvider for the Nordic countries
// (SE, NO, DK, FI). It encodes the standard statutory VAT rates:
//
// SE: 25 % (standard), 12 % (food, hotels), 6 % (books, transport)
// NO: 25 % (standard), 15 % (food), 12 %
// DK: 25 % (standard)
// FI: 25 % (standard; actual rate is 25.5 % as of 2026)
//
// All rates are raw percent (25 = 25 %). Fractional rates (FI 25.5) are
// truncated to the nearest integer per the raw-percent convention.
//
// NordicTaxProvider does not make network calls — all logic is local math.
// An external provider (Avalara, TaxJar) can be added by implementing
// TaxProvider and switching on TAX_PROVIDER.
type NordicTaxProvider struct {
defaultCountry string
}
// NewNordicTaxProvider returns a NordicTaxProvider. defaultCountry is used
// when DefaultTaxRate is called with an empty country code; it defaults to
// "SE" if empty.
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
if defaultCountry == "" {
defaultCountry = "SE"
}
return &NordicTaxProvider{defaultCountry: defaultCountry}
}
var _ TaxProvider = (*NordicTaxProvider)(nil)
func (p *NordicTaxProvider) Name() string { return "nordic" }
func (p *NordicTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// nordicStandardRates maps country code → default standard VAT rate (raw percent).
var nordicStandardRates = map[string]int{
"SE": 25,
"NO": 25,
"DK": 25,
"FI": 25,
}
func (p *NordicTaxProvider) DefaultTaxRate(country string) int {
if country == "" {
country = p.defaultCountry
}
if rate, ok := nordicStandardRates[country]; ok {
return rate
}
return nordicStandardRates[p.defaultCountry]
}
+113
View File
@@ -0,0 +1,113 @@
package cart
import (
"testing"
)
func TestNordicTaxProvider_Name(t *testing.T) {
p := NewNordicTaxProvider("SE")
if got := p.Name(); got != "nordic" {
t.Errorf("Name() = %q, want %q", got, "nordic")
}
}
func TestNordicTaxProvider_ComputeTax(t *testing.T) {
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{0, 25, 0, "zero total"},
{1250, 25, 250, "25 % VAT on 1250"},
{1000, 20, 166, "20 % VAT on 1000"},
{1200, 25, 240, "25 % VAT on 1200"},
{25000, 25, 5000, "25 % VAT on 25000"},
{100, 10, 9, "10 % VAT on 100"},
{100, 100, 50, "100 % VAT on 100"},
{100, 0, 0, "zero-rate"},
{100, -1, 0, "negative rate -> zero"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
got := p.ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
func TestNordicTaxProvider_DefaultTaxRate(t *testing.T) {
tests := []struct {
country string
want int
desc string
}{
{"SE", 25, "Sweden"},
{"NO", 25, "Norway"},
{"DK", 25, "Denmark"},
{"FI", 25, "Finland"},
{"", 25, "empty -> default SE"},
{"DE", 25, "unknown -> fallback SE"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
got := p.DefaultTaxRate(tt.country)
if got != tt.want {
t.Errorf("DefaultTaxRate(%q) [%s] = %d; want %d", tt.country, tt.desc, got, tt.want)
}
}
}
func TestStaticTaxProvider_ComputeTax(t *testing.T) {
p := NewStaticTaxProvider()
tests := []struct {
total int64
taxRate int
want int64
}{
{1250, 25, 250},
{25000, 25, 5000},
{1000, 20, 166},
{0, 25, 0},
}
for _, tt := range tests {
got := p.ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) = %d; want %d", tt.total, tt.taxRate, got, tt.want)
}
}
}
func TestStaticTaxProvider_DefaultTaxRate(t *testing.T) {
p := NewStaticTaxProvider()
if got := p.DefaultTaxRate("SE"); got != 0 {
t.Errorf("StaticTaxProvider.DefaultTaxRate(\"SE\") = %d; want 0", got)
}
}
func TestComputeTax(t *testing.T) {
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{1250, 25, 250, "canonical: 25 %"},
{25000, 25, 5000, "25k with 25 %% -> 5k tax"},
{0, 25, 0, "zero total"},
}
for _, tt := range tests {
got := ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
func TestTaxProviderImplementsInterface(t *testing.T) {
var p TaxProvider = NewNordicTaxProvider("SE")
_ = p
p2 := NewStaticTaxProvider()
_ = p2
}
+36 -2
View File
@@ -2,7 +2,9 @@ package discovery
import (
"context"
"os"
"slices"
"strings"
"sync"
v1 "k8s.io/api/core/v1"
@@ -13,14 +15,33 @@ import (
toolsWatch "k8s.io/client-go/tools/watch"
)
// InClusterNamespace returns the namespace the current pod runs in: POD_NAMESPACE
// (downward API) if set, else the service-account namespace file mounted into
// every pod. Empty means "not in a cluster" — callers can treat that as
// all-namespaces or single-node. Pass the result to NewK8sDiscoveryInNamespace
// to keep pod discovery (and thus the required RBAC) scoped to one namespace.
func InClusterNamespace() string {
if ns := os.Getenv("POD_NAMESPACE"); ns != "" {
return ns
}
if b, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil {
return strings.TrimSpace(string(b))
}
return ""
}
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
listOptions metav1.ListOptions
// namespace scopes the pod list/watch. Empty means all namespaces (requires
// a cluster-scoped RBAC role); a specific namespace only needs a namespaced
// Role, which is the least-privilege option.
namespace string
}
func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("")
return k.DiscoverInNamespace(k.namespace)
}
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, k.listOptions)
@@ -46,7 +67,7 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
ipsThatAreReady := make(map[string]bool)
m := sync.Mutex{}
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, k.listOptions)
return k.client.CoreV1().Pods(k.namespace).Watch(k.ctx, k.listOptions)
}
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
@@ -77,6 +98,8 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
return ch, nil
}
// NewK8sDiscovery watches pods across ALL namespaces (label-filtered via
// listOptions). It requires a cluster-scoped RBAC role for pod list/watch.
func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOptions) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
@@ -84,3 +107,14 @@ func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOption
listOptions: listOptions,
}
}
// NewK8sDiscoveryInNamespace watches pods only within namespace, so it needs
// just a namespaced Role (pods: get/list/watch) rather than a ClusterRole.
func NewK8sDiscoveryInNamespace(client *kubernetes.Clientset, namespace string, listOptions metav1.ListOptions) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
listOptions: listOptions,
namespace: namespace,
}
}
+101
View File
@@ -233,6 +233,103 @@ func HandleIssueRefund(o *OrderGrain, m *messages.IssueRefund) error {
return nil
}
// HandleRequestExchange records an exchange request.
func HandleRequestExchange(o *OrderGrain, m *messages.RequestExchange) error {
if o.Status != StatusFulfilled && o.Status != StatusCompleted && o.Status != StatusPartiallyFulfilled {
return fmt.Errorf("order: cannot request an exchange in status %q", o.Status)
}
ex := Exchange{
ID: m.GetId(),
ReturnID: m.GetReturnId(),
Reason: m.GetReason(),
RequestedAt: msToString(m.GetAtMs()),
}
// 1. Process return lines
for _, rl := range m.GetReturnLines() {
line := o.findLine(rl.GetReference())
if line == nil {
return fmt.Errorf("order: exchange return references unknown line %q", rl.GetReference())
}
ex.ReturnLines = append(ex.ReturnLines, FulfillmentEntry{
Reference: rl.GetReference(),
Quantity: int(rl.GetQuantity()),
})
}
// 2. Process new lines (replacement items)
for _, nl := range m.GetNewLines() {
line := Line{
Reference: nl.GetReference(),
Sku: nl.GetSku(),
Name: nl.GetName(),
Quantity: int(nl.GetQuantity()),
UnitPrice: nl.GetUnitPrice(),
TaxRate: int(nl.GetTaxRate()),
TotalAmount: nl.GetTotalAmount(),
TotalTax: nl.GetTotalTax(),
}
ex.NewLines = append(ex.NewLines, line)
// Add the replacement line to the order so it can be fulfilled
o.Lines = append(o.Lines, line)
}
// 3. Since there are new lines that are not yet fulfilled, transition order status back to partially fulfilled
o.Status = StatusPartiallyFulfilled
o.Exchanges = append(o.Exchanges, ex)
o.touch(ex.RequestedAt)
return nil
}
// HandleEditOrderDetails updates addresses or shipping price.
func HandleEditOrderDetails(o *OrderGrain, m *messages.EditOrderDetails) error {
if o.Status == StatusCompleted || o.Status == StatusCancelled || o.Status == StatusRefunded {
return fmt.Errorf("order: cannot edit details in status %q", o.Status)
}
if s := m.GetShippingAddress(); len(s) > 0 {
o.ShippingAddress = append([]byte(nil), s...)
}
if b := m.GetBillingAddress(); len(b) > 0 {
o.BillingAddress = append([]byte(nil), b...)
}
if sp := m.GetShippingPrice(); sp > 0 {
found := false
var oldTotal int64
for i := range o.Lines {
if o.Lines[i].Name == "Delivery" {
oldTotal = o.Lines[i].TotalAmount
o.Lines[i].UnitPrice = sp
o.Lines[i].TotalAmount = sp * int64(o.Lines[i].Quantity)
o.Lines[i].TotalTax = ComputeTax(o.Lines[i].TotalAmount, o.Lines[i].TaxRate)
o.TotalAmount = o.TotalAmount - oldTotal + o.Lines[i].TotalAmount
found = true
break
}
}
if !found {
line := Line{
Reference: "delivery",
Sku: "delivery",
Name: "Delivery",
Quantity: 1,
UnitPrice: sp,
TaxRate: 25,
TotalAmount: sp,
TotalTax: ComputeTax(sp, 25),
}
o.Lines = append(o.Lines, line)
o.TotalAmount += sp
}
}
o.touch(msToString(m.GetAtMs()))
return nil
}
// RegisterMutations registers every order mutation handler with the registry so
// the grain pool can apply and replay them.
func RegisterMutations(reg actor.MutationRegistry) {
@@ -245,5 +342,9 @@ func RegisterMutations(reg actor.MutationRegistry) {
actor.NewMutation(HandleCancelOrder),
actor.NewMutation(HandleRequestReturn),
actor.NewMutation(HandleIssueRefund),
actor.NewMutation(HandleRequestExchange),
actor.NewMutation(HandleEditOrderDetails),
)
}
+12
View File
@@ -101,6 +101,16 @@ type Return struct {
RequestedAt string `json:"requestedAt,omitempty"`
}
// Exchange records an exchange request.
type Exchange struct {
ID string `json:"id"`
ReturnID string `json:"returnId"`
Reason string `json:"reason,omitempty"`
ReturnLines []FulfillmentEntry `json:"returnLines,omitempty"`
NewLines []Line `json:"newLines,omitempty"`
RequestedAt string `json:"requestedAt,omitempty"`
}
// Refund records a refund issued against the order.
type Refund struct {
Provider string `json:"provider"`
@@ -137,8 +147,10 @@ type OrderGrain struct {
Payments []*Payment `json:"payments,omitempty"`
Fulfillments []Fulfillment `json:"fulfillments,omitempty"`
Returns []Return `json:"returns,omitempty"`
Exchanges []Exchange `json:"exchanges,omitempty"`
Refunds []Refund `json:"refunds,omitempty"`
CapturedAmount int64 `json:"capturedAmount"`
RefundedAmount int64 `json:"refundedAmount"`
+30
View File
@@ -0,0 +1,30 @@
package order
import "git.k6n.net/mats/go-cart-actor/pkg/cart"
// TaxProvider computes taxes for orders and line items.
// The canonical definition is in pkg/cart; this is a re-export for
// backward compatibility.
type TaxProvider = cart.TaxProvider
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
// Re-exported from pkg/cart for backward compatibility.
func ComputeTax(total int64, taxRate int) int64 {
return cart.ComputeTax(total, taxRate)
}
// NordicTaxProvider implements TaxProvider for the Nordic countries.
// Re-exported from pkg/cart for backward compatibility.
type NordicTaxProvider = cart.NordicTaxProvider
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
return cart.NewNordicTaxProvider(defaultCountry)
}
// StaticTaxProvider is a stateless TaxProvider with no country awareness.
// Re-exported from pkg/cart for backward compatibility.
type StaticTaxProvider = cart.StaticTaxProvider
func NewStaticTaxProvider() *StaticTaxProvider {
return cart.NewStaticTaxProvider()
}
+34
View File
@@ -0,0 +1,34 @@
package order
import (
"testing"
)
// TestReExports verifies that the order package's type aliases and
// forwarders to pkg/cart compile and work correctly.
func TestReExports(t *testing.T) {
// Type alias: TaxProvider = cart.TaxProvider
var _ TaxProvider = NewNordicTaxProvider("SE")
var _ TaxProvider = NewStaticTaxProvider()
// NordicTaxProvider
p := NewNordicTaxProvider("SE")
if p.Name() != "nordic" {
t.Errorf("Name = %q", p.Name())
}
if got := p.DefaultTaxRate("SE"); got != 25 {
t.Errorf("DefaultTaxRate(SE) = %d, want 25", got)
}
if got := ComputeTax(1250, 25); got != 250 {
t.Errorf("ComputeTax(1250, 25) = %d, want 250", got)
}
// StaticTaxProvider
s := NewStaticTaxProvider()
if s.Name() != "static" {
t.Errorf("Name = %q", s.Name())
}
if got := s.DefaultTaxRate("SE"); got != 0 {
t.Errorf("DefaultTaxRate(SE) = %d, want 0", got)
}
}
+1
View File
@@ -16,6 +16,7 @@ func NewProfileMutationRegistry() actor.MutationRegistry {
actor.NewMutation(HandleLinkCart),
actor.NewMutation(HandleLinkCheckout),
actor.NewMutation(HandleLinkOrder),
actor.NewMutation(HandleAnonymizeProfile),
)
return reg
}
+20
View File
@@ -0,0 +1,20 @@
package profile
import (
"fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
)
// HandleAnonymizeProfile wipes all PII from the customer profile.
func HandleAnonymizeProfile(p *ProfileGrain, m *messages.AnonymizeProfile) error {
if m == nil {
return fmt.Errorf("AnonymizeProfile: nil payload")
}
p.Name = ""
p.Email = ""
p.Phone = ""
p.AvatarUrl = ""
p.Addresses = nil // Clear all addresses containing PII
return nil
}
+367 -5
View File
@@ -2,6 +2,8 @@ package promotions
import (
"math"
"slices"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
@@ -28,7 +30,7 @@ type Effect interface {
// Apply mutates the cart for a qualifying action and returns the discount it
// took (nil for non-monetary effects such as free shipping) plus whether
// anything took effect worth recording.
Apply(g *cart.CartGrain, a Action) (discount *cart.Price, applied bool)
Apply(g *cart.CartGrain, rule PromotionRule, a Action) (discount *cart.Price, applied bool)
// Progress reports how far the cart is from (further) unlocking this action,
// as an open key/value payload. qualified says whether the owning rule
// currently applies, letting the effect distinguish "remaining to unlock"
@@ -43,6 +45,8 @@ func defaultEffects() map[ActionType]Effect {
ActionPercentageDiscount: percentageDiscountEffect{},
ActionTieredDiscount: tieredDiscountEffect{},
ActionFreeShipping: freeShippingEffect{},
ActionBuyXGetY: buyXGetYEffect{},
ActionBundleDiscount: bundleDiscountEffect{},
}
}
@@ -72,7 +76,7 @@ func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationR
}
recorded := false
if res.Applicable {
if discount, applied := eff.Apply(g, a); applied {
if discount, applied := eff.Apply(g, res.Rule, a); applied {
entry.Discount = discount
recorded = true
}
@@ -106,7 +110,7 @@ type percentageDiscountEffect struct{}
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
func (percentageDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
d := applyPercentageDiscount(g, percentFromValue(a.Value))
return d, d != nil
}
@@ -124,7 +128,7 @@ type freeShippingEffect struct{}
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ Action) (*cart.Price, bool) {
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, bool) {
return nil, true
}
@@ -143,7 +147,7 @@ type tieredDiscountEffect struct{}
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
func (tieredDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
d := applyTieredDiscount(g, a)
return d, d != nil
}
@@ -366,3 +370,361 @@ func firstNum(m map[string]interface{}, keys ...string) float64 {
}
return 0
}
// ----------------------------
// buyXGetYEffect implementation
// ----------------------------
type buyXGetYEffect struct{}
func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY }
type unitItem struct {
item *cart.CartItem
price int64
}
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
config := a.Config
buy := int(firstNum(config, "buy"))
if buy <= 0 {
buy = 1
}
get := int(firstNum(config, "get"))
if get <= 0 {
get = 1
}
discount := firstNum(config, "discount")
if discount <= 0 {
discount = 100.0 // default free
}
eligible := eligibleItems(rule, g)
var units []unitItem
for _, item := range eligible {
for k := uint16(0); k < item.Quantity; k++ {
units = append(units, unitItem{
item: item,
price: item.Price.IncVat,
})
}
}
n := len(units)
numFree := (n / (buy + get)) * get
if numFree <= 0 {
return nil, false
}
// Sort units by price ascending so we discount the cheapest ones
slices.SortFunc(units, func(x, y unitItem) int {
if x.price < y.price {
return -1
}
if x.price > y.price {
return 1
}
return 0
})
totalDiscount := cart.NewPrice()
for i := 0; i < numFree; i++ {
unitDiscount := scalePrice(&units[i].item.Price, discount)
totalDiscount.Add(*unitDiscount)
}
if totalDiscount.IncVat <= 0 {
return nil, false
}
// Never discount below zero.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
return totalDiscount, true
}
func (buyXGetYEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
return nil, false
}
// ----------------------------
// bundleDiscountEffect implementation
// ----------------------------
type bundleDiscountEffect struct{}
func (bundleDiscountEffect) Type() ActionType { return ActionBundleDiscount }
func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
if a.BundleConfig == nil {
return nil, false
}
// 1. Represent all cart items as individual units
type bundleUnit struct {
item *cart.CartItem
used bool
}
var units []*bundleUnit
for _, item := range g.Items {
if item == nil {
continue
}
for k := uint16(0); k < item.Quantity; k++ {
units = append(units, &bundleUnit{
item: item,
used: false,
})
}
}
// 2. Greedy match bundles
var formedBundles [][]*cart.CartItem
for {
// Attempt to form one bundle
var matchedUnits []*bundleUnit
possible := true
for _, container := range a.BundleConfig.Containers {
needed := container.Quantity
found := 0
var containerMatched []*bundleUnit
// Find unused units matching container's qualifyingRules
for _, u := range units {
if u.used {
continue
}
// Check if this unit is already matched in this bundle instance to avoid double counting
alreadyMatched := false
for _, mu := range matchedUnits {
if mu == u {
alreadyMatched = true
break
}
}
if alreadyMatched {
continue
}
if matchQualifyingRule(container.QualifyingRules, u.item) {
containerMatched = append(containerMatched, u)
found++
if found == needed {
break
}
}
}
if found < needed {
if a.BundleConfig.RequireAllContainers {
possible = false
break
}
} else {
matchedUnits = append(matchedUnits, containerMatched...)
}
}
if !possible || len(matchedUnits) == 0 {
break
}
// Mark matched units as used
var bundleItems []*cart.CartItem
for _, mu := range matchedUnits {
mu.used = true
bundleItems = append(bundleItems, mu.item)
}
formedBundles = append(formedBundles, bundleItems)
}
if len(formedBundles) == 0 {
return nil, false
}
totalDiscount := cart.NewPrice()
for _, bundle := range formedBundles {
// Calculate the original bundle price
bundlePrice := cart.NewPrice()
for _, item := range bundle {
// Add a single unit's price
bundlePrice.Add(item.Price)
}
if bundlePrice.IncVat <= 0 {
continue
}
var bundleDiscount *cart.Price
switch a.BundleConfig.Pricing.Type {
case "fixed_price":
targetPriceOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
if bundlePrice.IncVat > targetPriceOre {
pct := float64(bundlePrice.IncVat-targetPriceOre) / float64(bundlePrice.IncVat) * 100
bundleDiscount = scalePrice(bundlePrice, pct)
}
case "percentage_discount":
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
case "fixed_discount":
discountOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
pct := float64(discountOre) / float64(bundlePrice.IncVat) * 100
bundleDiscount = scalePrice(bundlePrice, pct)
}
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
totalDiscount.Add(*bundleDiscount)
}
}
if totalDiscount.IncVat <= 0 {
return nil, false
}
// Never discount below zero.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
return totalDiscount, true
}
func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
return nil, false
}
// ----------------------------
// Helpers
// ----------------------------
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
var categories []string
var productIDs []string
var walk func(conds Conditions)
walk = func(conds Conditions) {
for _, c := range conds {
switch v := c.(type) {
case ConditionGroup:
walk(v.Conditions)
case BaseCondition:
if v.Type == CondProductCategory {
if s, ok := v.Value.AsString(); ok {
categories = append(categories, strings.ToLower(s))
} else if arr, ok := v.Value.AsStringSlice(); ok {
for _, s := range arr {
categories = append(categories, strings.ToLower(s))
}
}
} else if v.Type == CondProductID {
if s, ok := v.Value.AsString(); ok {
productIDs = append(productIDs, strings.ToLower(s))
} else if arr, ok := v.Value.AsStringSlice(); ok {
for _, s := range arr {
productIDs = append(productIDs, strings.ToLower(s))
}
}
}
}
}
}
walk(rule.Conditions)
var out []*cart.CartItem
for _, item := range g.Items {
if item == nil {
continue
}
match := true
if len(categories) > 0 {
cat := ""
if item.Meta != nil {
cat = strings.ToLower(item.Meta.Category)
}
found := false
for _, c := range categories {
if c == cat {
found = true
break
}
}
if !found {
match = false
}
}
if len(productIDs) > 0 {
sku := strings.ToLower(item.Sku)
found := false
for _, p := range productIDs {
if p == sku {
found = true
break
}
}
if !found {
match = false
}
}
if match {
out = append(out, item)
}
}
return out
}
func matchQualifyingRule(rule BundleQualifyingRules, item *cart.CartItem) bool {
if item == nil {
return false
}
valStr, isStr := rule.Value.(string)
var valSlice []string
if !isStr {
if rawSlice, ok := rule.Value.([]interface{}); ok {
for _, val := range rawSlice {
if s, ok := val.(string); ok {
valSlice = append(valSlice, strings.ToLower(s))
}
}
} else if strSlice, ok := rule.Value.([]string); ok {
for _, s := range strSlice {
valSlice = append(valSlice, strings.ToLower(s))
}
}
} else {
valStr = strings.ToLower(valStr)
}
switch rule.Type {
case "category":
if item.Meta == nil {
return false
}
cat := strings.ToLower(item.Meta.Category)
if isStr {
return cat == valStr
}
for _, c := range valSlice {
if cat == c {
return true
}
}
return false
case "product_ids":
sku := strings.ToLower(item.Sku)
if isStr {
return sku == valStr
}
for _, s := range valSlice {
if sku == s {
return true
}
}
return false
case "all":
return true
default:
return false
}
}
+101
View File
@@ -146,3 +146,104 @@ func TestApplyResultsRecordsEffects(t *testing.T) {
}
})
}
func TestBuyXGetYEffect(t *testing.T) {
rule := PromotionRule{
ID: "buy2get1",
Name: "Buy 2 Get 1 Free",
Status: StatusActive,
Actions: []Action{
{
ID: "buy2get1-action",
Type: ActionBuyXGetY,
Config: map[string]interface{}{
"buy": 2.0,
"get": 1.0,
"discount": 100.0,
},
},
},
}
svc := NewPromotionService(nil)
// Cart with 3 items of price 1000 each. Buy 2 get 1 free -> 1 is free -> 1000 discount.
g := cart.NewCartGrain(1, timeZero())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "SKU1", Quantity: 3, Price: *cart.NewPriceFromIncVat(1000, 25)},
}
g.UpdateTotals()
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
if got := g.TotalDiscount.IncVat; got != 1000 {
t.Errorf("discount = %d, want 1000", got)
}
if got := g.TotalPrice.IncVat; got != 2000 {
t.Errorf("total price = %d, want 2000", got)
}
}
func TestBundleDiscountEffect(t *testing.T) {
rule := PromotionRule{
ID: "bundle-deal",
Name: "Bundle Deal",
Status: StatusActive,
Actions: []Action{
{
ID: "bundle-action",
Type: ActionBundleDiscount,
BundleConfig: &BundleConfig{
Containers: []BundleContainer{
{
ID: "shoes",
Name: "Shoes",
Quantity: 1,
SelectionType: "any",
QualifyingRules: BundleQualifyingRules{
Type: "category",
Value: "shoes",
},
},
{
ID: "socks",
Name: "Socks",
Quantity: 2,
SelectionType: "any",
QualifyingRules: BundleQualifyingRules{
Type: "category",
Value: "socks",
},
},
},
Pricing: BundlePricing{
Type: "fixed_price",
Value: 40.0, // 40 kr -> 4000 ore
},
RequireAllContainers: true,
},
},
},
}
svc := NewPromotionService(nil)
// Cart with 1 shoe (5000) and 2 socks (1000 each). Total original = 7000. Bundle fixed price is 4000. Discount = 3000.
g := cart.NewCartGrain(1, timeZero())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "shoe-1", Quantity: 1, Price: *cart.NewPriceFromIncVat(5000, 25), Meta: &cart.ItemMeta{Category: "shoes"}},
{Id: 2, Sku: "socks-1", Quantity: 2, Price: *cart.NewPriceFromIncVat(1000, 25), Meta: &cart.ItemMeta{Category: "socks"}},
}
g.UpdateTotals()
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
if got := g.TotalDiscount.IncVat; got != 3000 {
t.Errorf("discount = %d, want 3000", got)
}
if got := g.TotalPrice.IncVat; got != 4000 {
t.Errorf("total price = %d, want 4000", got)
}
}
+34 -2
View File
@@ -42,6 +42,7 @@ type PromotionEvalContext struct {
CustomerLifetimeValue float64
OrderCount int
Now time.Time
CouponCodes []string
}
// ContextOption allows customization of fields when building a PromotionEvalContext.
@@ -67,6 +68,11 @@ func WithNow(t time.Time) ContextOption {
return func(c *PromotionEvalContext) { c.Now = t }
}
// WithCouponCodes sets active coupon codes.
func WithCouponCodes(codes []string) ContextOption {
return func(c *PromotionEvalContext) { c.CouponCodes = codes }
}
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
ctx := &PromotionEvalContext{
@@ -74,6 +80,7 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
CartTotalIncVat: 0,
TotalItemQuantity: 0,
Now: time.Now(),
CouponCodes: make([]string, 0),
}
if g.TotalPrice != nil {
ctx.CartTotalIncVat = g.TotalPrice.IncVat
@@ -91,6 +98,9 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
})
ctx.TotalItemQuantity += uint32(it.Quantity)
}
for _, v := range g.Vouchers {
ctx.CouponCodes = append(ctx.CouponCodes, v.Code)
}
for _, o := range opts {
o(ctx)
}
@@ -106,18 +116,29 @@ type PromotionService struct {
// even one that has already applied (e.g. a tiered discount at 5% pointing at
// the next 9% tier). Register a new Effect to add a new action type.
effects map[ActionType]Effect
// DefaultTaxProvider provides the VAT rate used when constructing synthetic
// carts for stateless evaluation and preview. When nil, falls back to 25 %.
DefaultTaxProvider cart.TaxProvider
}
// NewPromotionService constructs a PromotionService with an optional tracer and
// the built-in effect handlers.
func NewPromotionService(t Tracer) *PromotionService {
// NewPromotionService constructs a PromotionService with an optional tracer.
// When tp is non-nil, it is used as DefaultTaxProvider for synthetic cart
// construction (stateless evaluation, preview); otherwise the service falls
// back to 25 % VAT.
func NewPromotionService(t Tracer, tp ...cart.TaxProvider) *PromotionService {
if t == nil {
t = NoopTracer{}
}
return &PromotionService{
s := &PromotionService{
tracer: t,
effects: defaultEffects(),
}
if len(tp) > 0 && tp[0] != nil {
s.DefaultTaxProvider = tp[0]
}
return s
}
// RegisterEffect adds (or overrides) the handler for an action type, so new
@@ -360,11 +381,22 @@ func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
return evalDayOfWeek(ctx.Now, b)
case CondTimeOfDay:
return evalTimeOfDay(ctx.Now, b)
case CondCouponCode:
return evalAnyCouponMatch(ctx.CouponCodes, b)
default:
return false
}
}
func evalAnyCouponMatch(codes []string, b BaseCondition) bool {
for _, code := range codes {
if evalStringCompare(code, b) {
return true
}
}
return false
}
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
if slices.ContainsFunc(ctx.Items, pred) {
return true
+39
View File
@@ -442,6 +442,45 @@ func TestDateRangeCondition(t *testing.T) {
}
}
func TestCouponCodeCondition(t *testing.T) {
rule := PromotionRule{
ID: "coupon-promo",
Name: "Coupon Promo",
Status: StatusActive,
Conditions: Conditions{
BaseCondition{
ID: "c",
Type: CondCouponCode,
Operator: OpEquals,
Value: cvString("SAVE50"),
},
},
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
}
svc := NewPromotionService(nil)
// Context with matching coupon code
ctxMatched := &PromotionEvalContext{
CouponCodes: []string{"SAVE50"},
Now: time.Now(),
}
resMatched := svc.EvaluateRule(rule, ctxMatched)
if !resMatched.Applicable {
t.Errorf("expected coupon code rule to apply with SAVE50")
}
// Context without matching coupon code
ctxUnmatched := &PromotionEvalContext{
CouponCodes: []string{"SAVE20"},
Now: time.Now(),
}
resUnmatched := svc.EvaluateRule(rule, ctxUnmatched)
if resUnmatched.Applicable {
t.Errorf("expected coupon code rule NOT to apply with SAVE20")
}
}
// --- Utilities -------------------------------------------------------------
func ptr(s string) *string { return &s }
+15 -4
View File
@@ -52,7 +52,7 @@ type EvaluateResponse struct {
// Evaluate runs rules against a synthetic cart built from req and returns the
// resulting totals and effects without touching any real cart state.
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
g := req.toCart()
g := req.toCart(s.DefaultTaxProvider)
g.UpdateTotals()
ctx := NewContextFromCart(g, req.contextOptions()...)
results, _ := s.EvaluateAll(rules, ctx)
@@ -65,7 +65,8 @@ func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest)
}
// toCart materialises the request into a throwaway CartGrain.
func (req EvaluateRequest) toCart() *cart.CartGrain {
// tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %.
func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
now := time.Now()
if req.Now != nil {
now = *req.Now
@@ -78,7 +79,7 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
}
vat := it.VatRate
if vat == 0 {
vat = 25
vat = defaultVatRate(tp)
}
item := &cart.CartItem{
Sku: it.Sku,
@@ -91,15 +92,25 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
g.Items = append(g.Items, item)
}
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
vat := defaultVatRate(tp)
g.Items = append(g.Items, &cart.CartItem{
Sku: "synthetic",
Quantity: 1,
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, 25),
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, vat),
})
}
return g
}
// defaultVatRate returns the default VAT rate (as a float32 for NewPriceFromIncVat)
// from the provider, or 25 if none is configured.
func defaultVatRate(tp cart.TaxProvider) float32 {
if tp == nil {
return 25
}
return float32(tp.DefaultTaxRate(""))
}
// contextOptions maps the optional customer/time fields onto context options.
func (req EvaluateRequest) contextOptions() []ContextOption {
var opts []ContextOption
+9
View File
@@ -133,6 +133,15 @@ func (s *Server) initialize(params json.RawMessage) map[string]any {
}
}
// defaultVatRate returns the default VAT rate from the eval service's
// configured tax provider, falling back to 25 if none is set.
func (s *Server) defaultVatRate() float32 {
if s.eval == nil || s.eval.DefaultTaxProvider == nil {
return 25
}
return float32(s.eval.DefaultTaxProvider.DefaultTaxRate(""))
}
func isBatch(body []byte) bool {
for _, b := range body {
switch b {
+2 -2
View File
@@ -86,7 +86,7 @@ func (s *Server) buildPublicTools() []tool {
if err := decode(args, &a); err != nil {
return nil, err
}
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
if a.CustomerSegment != "" {
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
@@ -141,7 +141,7 @@ func (s *Server) buildPublicTools() []tool {
rules = filtered
}
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
if a.CustomerSegment != "" {
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
+9 -5
View File
@@ -190,7 +190,7 @@ func (s *Server) buildTools() []tool {
if err := decode(args, &a); err != nil {
return nil, err
}
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
if a.CustomerSegment != "" {
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
@@ -210,15 +210,19 @@ func (s *Server) buildTools() []tool {
}
}
// syntheticCart builds a one-line cart whose gross total is incVat öre (25% VAT
// assumed) so the promotion engine can be evaluated against an arbitrary total.
func syntheticCart(incVat int64, qty int) *cart.CartGrain {
// syntheticCart builds a one-line cart whose gross total is incVat ore so the
// promotion engine can be evaluated against an arbitrary total. defaultVatRate
// is the VAT rate as a float32 percentage (e.g. 25 = 25 %).
func syntheticCart(incVat int64, qty int, defaultVatRate float32) *cart.CartGrain {
if qty <= 0 {
qty = 1
}
if defaultVatRate == 0 {
defaultVatRate = 25
}
g := cart.NewCartGrain(0, time.Now())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, 25)},
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, defaultVatRate)},
}
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
// pricing a single unit at the full total.
+1
View File
@@ -41,6 +41,7 @@ const (
CondDateRange ConditionType = "date_range"
CondDayOfWeek ConditionType = "day_of_week"
CondTimeOfDay ConditionType = "time_of_day"
CondCouponCode ConditionType = "coupon_code"
CondGroup ConditionType = "group" // synthetic value for groups
)
-3
View File
@@ -13,7 +13,6 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"google.golang.org/grpc"
@@ -39,7 +38,6 @@ const name = "proxy"
var (
tracer = otel.Tracer(name)
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
)
// MockResponseWriter implements http.ResponseWriter to capture responses for proxy calls.
@@ -292,7 +290,6 @@ func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request,
attribute.String("method", r.Method),
attribute.String("target", target),
)
logger.InfoContext(ctx, "proxying request", "cartid", id, "host", h.host, "method", r.Method)
var bdy io.Reader = r.Body
if customBody != nil {
+88
View File
@@ -0,0 +1,88 @@
// Package telemetry holds shared OpenTelemetry setup helpers used by the
// service main packages (cart, checkout, profile, …).
package telemetry
import (
"context"
"math/rand/v2"
"os"
"strconv"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/sdk/log"
)
// NewLoggerProvider builds the OTLP log provider from the environment, or returns
// (nil, nil) when logs are disabled — in which case the caller MUST skip
// global.SetLoggerProvider and the shutdown registration.
//
// Env:
//
// OTEL_LOGS_ENABLED "1"/"true"/"yes"/"on" to enable the OTLP log pipeline.
// Default OFF: the batch processor clones its record ring
// on every export, which profiling showed to be the
// dominant heap allocator in these services. Traces and
// metrics are always on regardless of this flag.
// OTEL_LOGS_SAMPLE_RATIO fraction of records to export, 0.01.0 (default 1.0).
// e.g. 0.1 keeps ~10% of logs. Dropped records never reach
// the batch ring, so allocation churn falls ~proportionally.
func NewLoggerProvider(ctx context.Context) (*log.LoggerProvider, error) {
if !logsEnabled() {
return nil, nil
}
exporter, err := otlploggrpc.New(ctx)
if err != nil {
return nil, err
}
var proc log.Processor = log.NewBatchProcessor(exporter)
if ratio := sampleRatio(); ratio < 1.0 {
proc = &samplingProcessor{next: proc, ratio: ratio}
}
return log.NewLoggerProvider(log.WithProcessor(proc)), nil
}
func logsEnabled() bool {
switch os.Getenv("OTEL_LOGS_ENABLED") {
case "1", "true", "TRUE", "True", "yes", "on":
return true
default:
return false
}
}
func sampleRatio() float64 {
v := os.Getenv("OTEL_LOGS_SAMPLE_RATIO")
if v == "" {
return 1.0
}
r, err := strconv.ParseFloat(v, 64)
if err != nil || r < 0 {
return 1.0
}
if r > 1 {
return 1.0
}
return r
}
// samplingProcessor forwards a random `ratio` fraction of records to the wrapped
// processor and drops the rest before they reach the (allocation-heavy) batch
// ring. This is head sampling: cheap, stateless, and per-record independent.
type samplingProcessor struct {
next log.Processor
ratio float64
}
func (s *samplingProcessor) Enabled(ctx context.Context, param log.EnabledParameters) bool {
return s.next.Enabled(ctx, param)
}
func (s *samplingProcessor) OnEmit(ctx context.Context, record *log.Record) error {
if rand.Float64() >= s.ratio {
return nil // dropped by sampling
}
return s.next.OnEmit(ctx, record)
}
func (s *samplingProcessor) Shutdown(ctx context.Context) error { return s.next.Shutdown(ctx) }
func (s *samplingProcessor) ForceFlush(ctx context.Context) error { return s.next.ForceFlush(ctx) }
+97
View File
@@ -0,0 +1,97 @@
package telemetry
import (
"context"
"errors"
"time"
"go.opentelemetry.io/otel"
"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/metric"
"go.opentelemetry.io/otel/sdk/trace"
)
// SetupOTelSDK bootstraps the OpenTelemetry pipeline (propagator + traces +
// metrics, and an opt-in logger — see NewLoggerProvider). It is shared by every
// service main package; the service name comes from OTEL_RESOURCE_ATTRIBUTES, so
// the same setup works everywhere. If it returns no error, call the returned
// shutdown for proper cleanup.
func SetupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
var shutdownFuncs []func(context.Context) error
var err error
// shutdown calls the registered cleanup functions, joining their errors.
shutdown := func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
// handleErr runs shutdown and surfaces all errors.
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
otel.SetTextMapPropagator(newPropagator())
tracerProvider, err := newTracerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
meterProvider, err := newMeterProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
// Logger provider is opt-in via OTEL_LOGS_ENABLED; nil means logs are off.
// Traces + metrics above are always on.
loggerProvider, err := NewLoggerProvider(ctx)
if err != nil {
handleErr(err)
return shutdown, err
}
if loggerProvider != nil {
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider)
}
return shutdown, err
}
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
}
return trace.NewTracerProvider(
trace.WithBatcher(traceExporter, trace.WithBatchTimeout(time.Second)),
), nil
}
func newMeterProvider() (*metric.MeterProvider, error) {
exporter, err := otlpmetricgrpc.New(context.Background())
if err != nil {
return nil, err
}
return metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter))), nil
}