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