all the refactor
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-28 17:51:52 +02:00
parent 7db0d236c7
commit aa8b2bdedc
84 changed files with 4328 additions and 2344 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin"
"git.k6n.net/mats/platform/config"
amqp "github.com/rabbitmq/amqp091-go"
"git.k6n.net/mats/platform/rabbit"
)
func main() {
@@ -66,9 +66,9 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var conn *amqp.Connection
var conn *rabbit.Conn
if amqpURL != "" {
conn, err = amqp.Dial(amqpURL)
conn, err = rabbit.Dial(amqpURL, "cart-backoffice")
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
+180 -31
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
@@ -10,6 +11,7 @@ import (
"os"
"os/signal"
"strings"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
@@ -21,8 +23,10 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/catalog"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/slask-finder/pkg/messaging"
"git.k6n.net/mats/platform/event"
"git.k6n.net/mats/platform/rabbit"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -116,6 +120,123 @@ func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem)
return false
}
// catalogProjectionCache is the cart's in-process map of SKU → catalog.Projection,
// updated by reading catalog.projection_published events off the bus. It is the
// cart-side source of truth for catalog facts (price, tax_class, inventory_tracked
// flag, currency, image, brand) used at add-to-cart / checkout-reserve decisions.
// Stock state is NOT cached here — exact qty is still queried synchronously from
// the inventory service at decision points per docs/inventory-shape.md.
//
// Concurrency: read-mostly workload justifies sync.RWMutex.
//
// Single-tier storage. The bus is the only writer — the cart does not separately
// seed from a snapshot file at boot, because the access pattern is point lookup
// of cart-active SKUs only, and coupling two sources (mmap snapshot + bus-fed
// map) created duplicated state with subtle drift risk and an mmap-SIGBUS hazard.
// Cold-grace: the cache may be incomplete for an SKU the cart sees before the
// first projection_published arrives for it; the cart falls back to the existing
// PRODUCT_BASE_URL HTTP fetch and overlays the cache fields on top of it.
//
// Deletes carry a tombstone in the map (not a plain delete) so a bus delete of
// an SKU that already fits the cache rules correctly shadows it for Get /
// IsDeleted lookups — preventing an oversight from re-fetching a deleted SKU
// before the cache rebuilds. Tombstones have a per-pod lifetime; bus-driven,
// so the next sweep on the same SKU flips the entry back to live.
type catalogProjectionCache struct {
mu sync.RWMutex
items map[string]projectionEntry
}
// projectionEntry is a cache slot: a live projection, or a tombstone (deleted)
// that shadows the live fallback.
type projectionEntry struct {
proj catalog.Projection
deleted bool
}
func newCatalogProjectionCache() *catalogProjectionCache {
return &catalogProjectionCache{items: make(map[string]projectionEntry, 8192)}
}
func (c *catalogProjectionCache) Apply(updates []catalog.ProjectionUpdate) (upserts, deletes int) {
c.mu.Lock()
defer c.mu.Unlock()
for _, u := range updates {
if u.Deleted {
c.items[u.SKU] = projectionEntry{deleted: true}
deletes++
continue
}
if u.SKU == "" {
continue
}
c.items[u.SKU] = projectionEntry{proj: u.Projection}
upserts++
}
return
}
func (c *catalogProjectionCache) Get(sku string) (catalog.Projection, bool) {
c.mu.RLock()
e, ok := c.items[sku]
c.mu.RUnlock()
if !ok {
return catalog.Projection{}, false
}
if e.deleted {
return catalog.Projection{}, false
}
return e.proj, true
}
// IsDeleted reports whether a tombstone exists for sku — used by HTTP-fetch
// call sites to skip the fallback for an SKU the bus has explicitly deleted,
// avoiding a wasteful round-trip on items the writer has just removed.
func (c *catalogProjectionCache) IsDeleted(sku string) bool {
c.mu.RLock()
e, ok := c.items[sku]
c.mu.RUnlock()
return ok && e.deleted
}
// Len reports the number of map entries (bus-fed upserts + tombstones). Used
// for log gauges only.
func (c *catalogProjectionCache) Len() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.items)
}
// projectionDeliveryHandler is the AMQP delivery handler for
// `catalog.projection_published` on the `catalog` topic exchange. Decodes the
// []ProjectionUpdate batch and merges it into cache. Returns nil on a batch
// that doesn't apply locally so the AMQP delivery is acked; returns an error
// on a decode failure so the broker knows to redeliver / quarantine.
//
// Recovery: any panic in a single event is caught so the goroutine keeps
// draining the queue; the bus stays live across malformed events.
func projectionDeliveryHandler(cache *catalogProjectionCache) func(amqp.Delivery) error {
return func(d amqp.Delivery) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("projection handler panic recovered: %v", r)
}
}()
var ev event.Event
if err := json.Unmarshal(d.Body, &ev); err != nil {
return fmt.Errorf("decode event envelope: %w", err)
}
updates, err := catalog.DecodeUpdates(ev.Payload)
if err != nil {
return fmt.Errorf("decode projection updates: %w", err)
}
upserts, deletes := cache.Apply(updates)
log.Printf("cart: catalog projection batch applied: upserts=%d deletes=%d total=%d eventID=%s source=%q",
upserts, deletes, cache.Len(), ev.ID, ev.Source)
return nil
}
}
func main() {
// cartPort is the bare HTTP port. It drives both the local listener and the
@@ -282,29 +403,48 @@ func main() {
cartMCP := cartmcp.New(pool)
// Publish each applied mutation to the "cart"/"mutation" RabbitMQ topic so the
// backoffice /commerce live feed (and other consumers) see cart activity.
// Best-effort: if AMQP is unreachable the cart still serves; the feed stays empty.
// Stream each applied mutation to the shared "mutations" exchange (routing key
// mutation.cart) so the backoffice /commerce live feed (and other consumers)
// see cart activity. Best-effort: if AMQP is unreachable the cart still serves.
if amqpUrl != "" {
if conn, derr := amqp.Dial(amqpUrl); derr != nil {
if conn, derr := rabbit.Dial(amqpUrl, "cart"); derr != nil {
log.Printf("cart: AMQP connect failed, mutation feed disabled: %v", derr)
} else {
if ch, cerr := conn.Channel(); cerr == nil {
_ = messaging.DefineTopic(ch, "cart", "mutation") // idempotent; non-fatal
_ = ch.Close()
}
pool.AddListener(actor.NewAmqpListener(conn, func(id uint64, results []actor.ApplyResult) (any, error) {
types := make([]string, 0, len(results))
for _, r := range results {
types = append(types, r.Type)
}
return map[string]any{"cartId": id, "mutations": types}, nil
}))
log.Printf("cart: mutation feed enabled (cart/mutation)")
listener := actor.NewAmqpListener(conn.Connection(), "cart", actor.MutationSummary)
listener.DefineTopics()
pool.AddListener(listener)
log.Printf("cart: mutation feed enabled (mutation.cart)")
}
}
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp)) //inventoryService, inventoryReservationService)
// catalog.projection_published consumer (the cart is the canonical
// consumer of the projection wire per docs/inventory-shape.md; inventory
// emits level crossings back to finder only, never the reverse). Own
// connection keeps the cart's mutation-feed lifecycle decoupled from a
// read-write-consume connection that could grow event handlers later
// (e.g. promotions reacting to a price drop on a watched SKU).
projectionCache := newCatalogProjectionCache()
if amqpUrl != "" {
if pConn, perr := rabbit.Dial(amqpUrl, "cart-catalog-projection"); perr != nil {
log.Printf("cart: catalog projection consumer DISABLED (dial failed): %v", perr)
} else {
pConn.NotifyOnReconnect(func() {
ch, err := pConn.Channel()
if err != nil {
log.Printf("cart: channel on projection reconnect: %v", err)
return
}
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), projectionDeliveryHandler(projectionCache)); err != nil {
log.Printf("cart: bind catalog.projection_published on reconnect: %v", err)
_ = ch.Close()
}
})
defer func() { _ = pConn.Close() }()
log.Printf("cart: catalog projection consumer ENABLED (catalog.projection_published on 'catalog' exchange)")
}
}
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), projectionCache) //inventoryService, inventoryReservationService)
app := &App{
pool: pool,
@@ -374,6 +514,23 @@ func main() {
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
debugMux.Handle("/metrics", promhttp.Handler())
// Projection cache probe (dev/verification). GET /debug/projection/{sku} →
// the resolved projection + the bus-fed map size. map_entries counts both
// live projections and tombstones; a found=true response confirms the bus
// consumer delivered the SKU to this pod.
debugMux.HandleFunc("/debug/projection/", func(w http.ResponseWriter, r *http.Request) {
sku := strings.TrimPrefix(r.URL.Path, "/debug/projection/")
p, found := projectionCache.Get(sku)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sku": sku,
"found": found,
"projection": p,
"map_entries": projectionCache.Len(),
"deleted": projectionCache.IsDeleted(sku),
})
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
grainCount, capacity := app.pool.LocalUsage()
@@ -434,19 +591,11 @@ func main() {
srvErr <- srv.ListenAndServe()
}()
// listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
// for _, change := range changes {
// log.Printf("inventory change: %v", change)
// inventoryPubSub.Publish(change)
// }
// })
// go func() {
// err := listener.Start()
// if err != nil {
// log.Fatalf("Unable to start inventory listener: %v", err)
// }
// }()
// Inventory change consumption used to live here over the bare Redis
// `inventory_changed` channel; it is now owned by the cart-inventory
// service, which translates quantity changes into inventory.level_changed
// bus crossings. The cart reads exact stock synchronously when it needs it.
// See docs/inventory-shape.md.
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
+54 -5
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -41,12 +42,19 @@ var (
type PoolServer struct {
actor.GrainPool[cart.CartGrain]
pod_name string
// idx is the bus-fed catalog projection cache, consulted at HTTP-fetch
// sites (AddSkuToCartHandler, buildItemGroups) to overlay authoritative
// price / tax_class / display fields onto AddItem before mutation. nil is
// tolerated so handlers still serve if the bus consumer is unavailable
// (orphan / quarantine / CI), with a plain cache-miss fall-through.
idx *catalogProjectionCache
}
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string) *PoolServer {
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, idx *catalogProjectionCache) *PoolServer {
srv := &PoolServer{
GrainPool: pool,
pod_name: pod_name,
idx: idx,
}
return srv
@@ -67,10 +75,26 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id c
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
sku := r.PathValue("sku")
if s.idx != nil && s.idx.IsDeleted(sku) {
// Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the
// product-fetcher's "product service returned %d for sku %s" shape so
// upstream code that pattern-matches that string still works, AND we
// publish StatusNotFound so the HTTP response carries the right code
// (the handler's error-return path otherwise renders as 500).
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(fmt.Sprintf(`{"error":"product service returned %d for sku %s (bus-deleted)","sku":%q}`, 404, sku, sku)))
return nil
}
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
if err != nil {
return err
}
if s.idx != nil {
if p, ok := s.idx.Get(sku); ok {
ApplyProjectionOverlay(msg, p)
}
}
data, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
@@ -154,16 +178,32 @@ type itemGroup struct {
// drive child pricing, then children are fetched concurrently. Input order is
// preserved. Items that fail to fetch are skipped (logged), matching the prior
// best-effort behavior.
func buildItemGroups(ctx context.Context, items []Item, country string) []itemGroup {
//
// idx is the bus-fed projection cache; when non-nil, every AddItem built here
// has its cache-covered fields (price/tax_class/display/inventory_tracked/
// drop_ship) overlaid onto the HTTP-fetched answer. Cache-hit calls naturally
// become authoritative for what the projection carries; HTTP stays for
// dimensions (parent width/height for child pricing), seller/orgPrice and the
// dynamic ExtraJson product data. Skipped cleanly when idx is nil.
func buildItemGroups(ctx context.Context, items []Item, country string, idx *catalogProjectionCache) []itemGroup {
groups := make([]itemGroup, len(items))
wg := sync.WaitGroup{}
for i, itm := range items {
wg.Go(func() {
if idx != nil && idx.IsDeleted(itm.Sku) {
log.Printf("error adding item %s: bus-deleted (skipping)", itm.Sku)
return
}
parentMsg, parentProduct, err := BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
if err != nil {
log.Printf("error adding item %s: %v", itm.Sku, err)
return
}
if idx != nil {
if p, ok := idx.Get(itm.Sku); ok {
ApplyProjectionOverlay(parentMsg, p)
}
}
parentMsg.CustomFields = itm.CustomFields
groups[i].parent = parentMsg
@@ -174,11 +214,20 @@ func buildItemGroups(ctx context.Context, items []Item, country string) []itemGr
cwg := sync.WaitGroup{}
for j, child := range itm.Children {
cwg.Go(func() {
if idx != nil && idx.IsDeleted(child.Sku) {
log.Printf("error adding child %s of %s: bus-deleted (skipping)", child.Sku, itm.Sku)
return
}
childMsg, _, err := BuildItemMessage(ctx, child.Sku, child.Quantity, country, child.StoreId, parentProduct)
if err != nil {
log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err)
return
}
if idx != nil {
if p, ok := idx.Get(child.Sku); ok {
ApplyProjectionOverlay(childMsg, p)
}
}
childMsg.CustomFields = child.CustomFields
children[j] = childMsg
})
@@ -263,7 +312,7 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
return err
}
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
reply, err := s.applyItemGroups(r.Context(), id, groups)
if err != nil {
return err
@@ -286,7 +335,7 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque
return err
}
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
reply, err := s.applyItemGroups(r.Context(), id, groups)
if err != nil {
return err
@@ -326,7 +375,7 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
Children: addRequest.Children,
CustomFields: addRequest.CustomFields,
}
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country)
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country, s.idx)
reply, err := s.applyItemGroups(r.Context(), id, groups)
if err != nil {
return err
+3
View File
@@ -201,6 +201,9 @@ func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, q
Name: item.Title,
Image: item.Img,
Stock: stock,
// item.Vat is the product's VAT as a raw integer percent (e.g. 25);
// ×100 converts to the platform basis-point scale (2500). This is the
// single conversion boundary — everything downstream is basis points.
Tax: int32(item.Vat * 100),
SellerId: strconv.Itoa(item.SupplierId),
SellerName: item.SupplierName,
+2 -2
View File
@@ -56,7 +56,7 @@ func newTestPoolServer(t *testing.T) *PoolServer {
if err != nil {
t.Fatalf("new pool: %v", err)
}
return NewPoolServer(pool, "test")
return NewPoolServer(pool, "test", nil)
}
// exampleId is the catalog item the fetcher rework was validated against.
@@ -338,7 +338,7 @@ func TestChildren_ParentLinkage(t *testing.T) {
Children: []Item{{Sku: exampleId, Quantity: 1, StoreId: &childStore}},
}}
groups := buildItemGroups(ctx, items, "se")
groups := buildItemGroups(ctx, items, "se", nil)
if len(groups) != 1 {
t.Fatalf("groups = %d, want 1", len(groups))
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"testing"
"git.k6n.net/mats/platform/catalog"
"git.k6n.net/mats/platform/money"
)
// TestProjectionCache_ApplyUpserts covers the happy path: a batch of N
// projections reaches the cache via Apply, all are visible via Get.
func TestProjectionCache_ApplyUpserts(t *testing.T) {
c := newCatalogProjectionCache()
updates := []catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}},
{Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}},
{Projection: catalog.Projection{ID: "id-C", SKU: "C", PriceIncVat: money.Cents(0), TaxClass: ""}},
}
upserts, deletes := c.Apply(updates)
if upserts != 3 || deletes != 0 {
t.Fatalf("counts: upserts=%d deletes=%d, want 3/0", upserts, deletes)
}
for _, want := range []struct {
sku string
price money.Cents
taxCls string
}{
{"A", money.Cents(100_00), "standard"},
{"B", money.Cents(150_00), "reduced"},
{"C", money.Cents(0), ""},
} {
got, ok := c.Get(want.sku)
if !ok {
t.Fatalf("Get(%q): missing", want.sku)
}
if got.PriceIncVat != want.price || got.TaxClass != want.taxCls {
t.Fatalf("Get(%q) = %+v, want price=%d taxCls=%q", want.sku, got, want.price, want.taxCls)
}
}
if c.Len() != 3 {
t.Fatalf("Len = %d, want 3", c.Len())
}
}
// TestProjectionCache_DeleteTombstone verifies that a delete update sets a
// tombstone (not removes the entry), so:
// - Get returns (_, false) on a deleted SKU
// - IsDeleted returns true
// - the same entry continues to count toward Len (size accounting)
func TestProjectionCache_DeleteTombstone(t *testing.T) {
c := newCatalogProjectionCache()
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}},
})
if _, ok := c.Get("A"); !ok {
t.Fatalf("A: expected hit before delete")
}
upserts, deletes := c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
})
if upserts != 0 || deletes != 1 {
t.Fatalf("delete counts: upserts=%d deletes=%d, want 0/1", upserts, deletes)
}
if got, ok := c.Get("A"); ok {
t.Fatalf("A after delete: got %+v ok=true, want miss", got)
}
if !c.IsDeleted("A") {
t.Fatalf("A after delete: IsDeleted false")
}
if c.IsDeleted("UNKNOWN") {
t.Fatalf("UNKNOWN: IsDeleted should be false (entry doesn't exist)")
}
if c.Len() != 1 {
t.Fatalf("Len after tombstone: %d, want 1 (tombstone still in map)", c.Len())
}
}
// TestProjectionCache_BusResurrect verifies that a fresh upsert after a delete
// clears the tombstone (live entry replaces it): Get returns the projection,
// IsDeleted flips to false. Mirrors how the live catalog keeps flipping state.
func TestProjectionCache_BusResurrect(t *testing.T) {
c := newCatalogProjectionCache()
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
})
if !c.IsDeleted("A") {
t.Fatalf("A: not tombstoned after delete")
}
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(75_00)}},
})
got, ok := c.Get("A")
if !ok {
t.Fatalf("A: expected live after re-upsert")
}
if got.PriceIncVat != money.Cents(75_00) {
t.Fatalf("A re-upsert price = %d, want 7500", got.PriceIncVat)
}
if c.IsDeleted("A") {
t.Fatalf("A: still tombstoned after re-upsert")
}
}
// TestProjectionCache_EmptySKUIgnored validates that an upsert with an empty
// SKU is a no-op (defensive — bus should never publish "" but cheaper to log
// + drop than to populate an ambiguous cache slot).
func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
c := newCatalogProjectionCache()
upserts, _ := c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}},
})
if upserts != 0 {
t.Fatalf("empty-SKU upsert: %d, want 0", upserts)
}
if c.Len() != 0 {
t.Fatalf("Len after empty-SKU: %d, want 0", c.Len())
}
}
// TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race
// to lock down the lock order. Run via `go test -race ./cmd/cart/...`.
func TestProjectionCache_BusRaceSafe(t *testing.T) {
c := newCatalogProjectionCache()
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 500; i++ {
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "X", PriceIncVat: money.Cents(int64(i * 100))}},
})
}
}()
for i := 0; i < 2000; i++ {
_, _ = c.Get("X")
_ = c.IsDeleted("X")
_ = c.Len()
}
<-done
if _, ok := c.Get("X"); !ok {
t.Fatalf("final Get(X): miss after concurrent writes")
}
}
// TestTaxResolveBp covers the static TaxClass→bp mapping used by
// ApplyProjectionOverlay. Adds a regression net for the common Nordic rates.
func TestTaxResolveBp(t *testing.T) {
cases := []struct {
class string
want int
}{
{"standard", 2500},
{"reduced", 1200},
{"lowered", 600},
{"zero", 0},
{"exempt", 0},
{"", 2500}, // missing class defaults to standard (matches mutation registry)
{"unknown-class", 2500},
}
for _, tc := range cases {
got := resolveTaxBp(tc.class)
if got != tc.want {
t.Errorf("resolveTaxBp(%q) = %d, want %d", tc.class, got, tc.want)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/platform/catalog"
)
// taxClassToBp maps the canonical TaxClass string identifiers published on
// catalog.Projection to basis-points-of-a-percent (rate × 100) — the single
// platform-wide rate scale used by AddItem.Tax (int32 basis points), the
// mutation registry, and the tax.Provider Compute formula.
//
// Values reflect the standard tariff names used by the Nordic configuration.
// Per-country / per-tenant numeric resolution (e.g. Finland 25.5%, Ireland
// 13.5%) is delegated to platform/tax and is a follow-up: this static map
// gets the common case right (2500 / 1200 / 600 / 0) and lets the cache path
// ship while the platform/tax lookup is wired into the cart pool server.
var taxClassToBp = map[string]int{
"standard": 2500,
"reduced": 1200, // common reduced rate (e.g. SE/NO food)
"lowered": 600, // super-reduced
"zero": 0, // zero-rated (export, healthcare, ...)
"exempt": 0, // similarly untaxed, separate nominal class
}
// resolveTaxBp returns the basis-point rate for a TaxClass string, falling
// back to platform "standard" (25%) when the class is unrecognised. The
// default matches the grain's existing behaviour (AddItem falls back to 2500
// when m.Tax == 0 — see pkg/cart/mutation_add_item.go).
func resolveTaxBp(class string) int {
if bp, ok := taxClassToBp[class]; ok {
return bp
}
return 2500
}
// ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto
// an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch.
//
// Authoritative-from-cache fields (these WIN over the HTTP-fetched value when
// both are present, because the bus is the live stream):
//
// Price (bus has post-class-overrides; product service has static)
// Tax (TaxClass → bp via resolveTaxBp; only when TaxClass is set —
// an empty TaxClass means the producer didn't classify,
// so we leave the HTTP-fetched rate intact rather than
// quietly swapping in a default 2500 and miscategorising)
// Title (canonical, post-trim)
// Image (canonical)
// InventoryTracked (only flips to true; false is the zero value in proto
// and may be unset on the wire, so we don't second-guess)
// DropShip (only flips to true; same reasoning)
//
// The HTTP-fetched values remain authoritative for fields the cache does not
// carry: SellerId / SellerName (marketplace split), Stock (per docs/inventory-
// shape.md stock is queried synchronously from inventory, not cached),
// OrgPrice / Discount (not yet on the projection schema), the dynamic
// ExtraJson product data (configurator options — width/height used by
// accessory child-pricing), and the catalog uint32 ItemId (the product
// service's integer id which messages.AddItem.ItemId dedupes on — Projection
// only carries the opaque string ID, so a cache-only build path is blocked
// and the HTTP fetch is preserved).
//
// Tombstoned SKUs (catalog.projection_published with deleted=true) → the
// caller checks idx.IsDeleted(sku) before this helper and short-circuits.
func ApplyProjectionOverlay(msg *messages.AddItem, p catalog.Projection) {
if msg == nil {
return
}
// Price: positive bus values override HTTP. A zero PriceIncVat is treated
// as "no value" rather than "free".
if p.PriceIncVat.Int64() > 0 {
msg.Price = p.PriceIncVat.Int64()
}
// Tax: only override when the bus has classified the SKU. An empty TaxClass
// means the producer had nothing to say about VAT; the HTTP rate is more
// trustworthy in that case than a guessed 2500.
if p.TaxClass != "" {
msg.Tax = int32(resolveTaxBp(p.TaxClass))
}
// Display fields: only override if non-empty so the HTTP-fanned value
// remains in place when the cache hasn't populated them yet.
if p.Title != "" {
msg.Name = p.Title
}
if p.Image != "" {
msg.Image = p.Image
}
// Flags: only flip to true (positive boolean signals). A false is the
// proto-zero value and not worth overriding.
if p.InventoryTracked {
msg.InventoryTracked = true
}
if p.DropShip {
msg.DropShip = true
}
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"testing"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/platform/catalog"
"git.k6n.net/mats/platform/money"
)
// TestApplyProjectionOverlay_PricePositiveOnly covers the contract that a cache
// hit with a positive PriceIncVat overrides HTTP-fetched price, but a zero
// (unbroadcast) PriceIncVat must NOT overwrite a valid HTTP price — it would
// be a silent corruption.
func TestApplyProjectionOverlay_PricePositiveOnly(t *testing.T) {
startPrice := int64(95_00)
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", Price: startPrice, Name: "http-name", Image: "http.jpg", Tax: 2500}
}
// Positive cache value wins.
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg", PriceIncVat: money.Cents(120_00), TaxClass: "reduced"})
if m.Price != 120_00 {
t.Errorf("positive cache price: got %d, want 12000", m.Price)
}
// Zero cache value must NOT overwrite (treat as "no value").
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", PriceIncVat: money.Cents(0)})
if m.Price != startPrice {
t.Errorf("zero cache price overwrote HTTP price: got %d, want %d (HTTP)", m.Price, startPrice)
}
}
// TestApplyProjectionOverlay_TaxOnlyWhenClassSet verifies the TaxClass-skip on
// an unclassified cache entry: HTTP rate is more trustworthy than a guessed
// 2500 default, so msg.Tax stays untouched.
func TestApplyProjectionOverlay_TaxOnlyWhenClassSet(t *testing.T) {
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", Tax: 600} // HTTP-fetched lowered 6%
}
// Empty TaxClass: HTTP rate preserved.
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: ""})
if m.Tax != 600 {
t.Errorf("empty TaxClass overwrote HTTP tax: got %d, want 600", m.Tax)
}
// Non-empty: cache rate wins.
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "reduced"})
if m.Tax != 1200 {
t.Errorf("TaxClass=reduced: got %d, want 1200", m.Tax)
}
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "zero"})
if m.Tax != 0 {
t.Errorf("TaxClass=zero: got %d, want 0", m.Tax)
}
}
// TestApplyProjectionOverlay_DisplayFields verifies Title/Image override only
// when the cache actually carries a value (an unbus-fed "" isn't propagated
// over a valid HTTP value).
func TestApplyProjectionOverlay_DisplayFields(t *testing.T) {
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", Name: "http-name", Image: "http.jpg"}
}
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg"})
if m.Name != "cache-name" {
t.Errorf("Title overlay: got %q, want %q", m.Name, "cache-name")
}
if m.Image != "cache.jpg" {
t.Errorf("Image overlay: got %q, want %q", m.Image, "cache.jpg")
}
// Empty cache Title/Image must NOT overwrite.
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "", Image: ""})
if m.Name != "http-name" {
t.Errorf("empty cache Title wiped HTTP name: got %q", m.Name)
}
if m.Image != "http.jpg" {
t.Errorf("empty cache Image wiped HTTP image: got %q", m.Image)
}
}
// TestApplyProjectionOverlay_FlagFlipToTrueOnly locks down the
// InventoryTracked / DropShip positive-flip semantics. False in the cache
// is the proto zero value and may be unset on the wire; we don't second-guess.
func TestApplyProjectionOverlay_FlagFlipToTrueOnly(t *testing.T) {
// HTTP set both true (e.g. live SKU). Cache also has them true. Should
// stay true.
m := &messages.AddItem{InventoryTracked: true, DropShip: true}
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
if !m.InventoryTracked || !m.DropShip {
t.Fatalf("true-from-cache over a true-from-HTTP: %+v", m)
}
// HTTP set false (default), cache says true → upgrade to true.
m = &messages.AddItem{InventoryTracked: false, DropShip: false}
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
if !m.InventoryTracked || !m.DropShip {
t.Errorf("true-from-cache should flip false-from-HTTP: %+v", m)
}
// HTTP already true, cache says false → must NOT downgrade (false would
// be the proto zero value; trust the live HTTP signal here).
m = &messages.AddItem{InventoryTracked: true, DropShip: true}
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: false, DropShip: false})
if !m.InventoryTracked || !m.DropShip {
t.Errorf("false-from-cache must NOT overwrite true-from-HTTP: %+v", m)
}
}
// TestApplyProjectionOverlay_NilMsgSafe confirms the helper tolerates a nil
// pointer (defensive — caller already validates, but a future caller may not).
func TestApplyProjectionOverlay_NilMsgSafe(t *testing.T) {
// Must not panic.
ApplyProjectionOverlay(nil, catalog.Projection{SKU: "X"})
}
// TestIsDeletedVsGet asserts the two methods don't conflict: a tombstoned SKU
// is reported by both IsDeleted (true) and Get (miss).
func TestIsDeletedVsGet(t *testing.T) {
c := newCatalogProjectionCache()
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "DL", PriceIncVat: money.Cents(10_00)}},
{Projection: catalog.Projection{SKU: "DL"}, Deleted: true},
})
if _, ok := c.Get("DL"); ok {
t.Fatalf("Get on tombstone: ok=true")
}
if !c.IsDeleted("DL") {
t.Fatalf("IsDeleted on tombstone: false")
}
if c.IsDeleted("DL-fresh") {
t.Fatalf("IsDeleted on absent entry: true")
}
if _, ok := c.Get("DL-fresh"); ok {
t.Fatalf("Get on absent entry: ok=true")
}
}
+3 -22
View File
@@ -12,7 +12,6 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
@@ -232,27 +231,9 @@ func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId ca
return
}
if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved {
if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil {
log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr)
} else {
// Commit step: release cart reservation since we permanently decremented stock
if s.reservationService != nil {
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
log.Printf("from-checkout: failed to release cart reservation for %s: %v", item.Sku, err)
}
}
}
s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: "success",
})
}
}
// Inventory is NOT committed here — see the note in KlarnaPushHandler. The
// order saga emits order.created and the inventory service reacts
// (commit + release + level crossing), idempotently and off the revenue path.
country := getCountryFromCurrency(item.Amount.Currency)
if _, oerr := createOrderFromSettledCheckout(ctx, s, grain, settledPayment{
+18 -14
View File
@@ -9,6 +9,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/platform/tax"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
)
@@ -66,7 +67,7 @@ type CheckoutMeta struct {
// CartItem / Delivery to expose that data and propagate it here.
// tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for
// delivery lines instead of the hardcoded 2500 (25 % as CartItem format).
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) ([]byte, *CheckoutOrder, error) {
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
@@ -177,19 +178,22 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
}
// defaultKlarnaTaxRate returns the default tax rate for the purchase country, in Klarna
// format (percent x 100, e.g. 2500 = 25.00 %). When tp is nil, falls back to 2500 (25 %).
func defaultKlarnaTaxRate(tp cart.TaxProvider, country string) int {
// format (percent x 100, e.g. 2500 = 25.00 %). This matches the platform basis-point
// scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls back to 2500.
func defaultKlarnaTaxRate(tp tax.Provider, country string) int {
if tp == nil {
return 2500
}
return tp.DefaultTaxRate(country) * 100
return tp.DefaultTaxRate(country)
}
// defaultAdyenTaxRate returns the default tax rate for the purchase country, in Adyen
// format (raw percent, e.g. 25 = 25 %). When tp is nil, falls back to 25.
func defaultAdyenTaxRate(tp cart.TaxProvider, country string) int64 {
// format (taxPercentage in basis points, e.g. 2500 = 25 %). This matches the platform
// basis-point scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls
// back to 2500.
func defaultAdyenTaxRate(tp tax.Provider, country string) int64 {
if tp == nil {
return 25
return 2500
}
return int64(tp.DefaultTaxRate(country))
}
@@ -211,7 +215,7 @@ func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
}
}
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
if grain == nil {
return nil, fmt.Errorf("nil grain")
}
@@ -237,10 +241,10 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(int64(it.Quantity)),
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat),
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat.Int64()),
Description: common.PtrString(it.Meta.Name),
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat().Int64()),
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat().Int64()),
TaxPercentage: common.PtrInt64(int64(it.Tax)),
})
}
@@ -263,8 +267,8 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
if d == nil {
continue
}
amountIncTax := d.Price.IncVat
amountExTax := d.Price.ValueExVat()
amountIncTax := d.Price.IncVat.Int64()
amountExTax := d.Price.ValueExVat().Int64()
if hasFreeShipping {
amountIncTax = 0
amountExTax = 0
@@ -284,7 +288,7 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
return &adyenCheckout.CreateCheckoutSessionRequest{
Reference: grain.Id.String(),
Amount: adyenCheckout.Amount{
Value: total.IncVat,
Value: total.IncVat.Int64(),
Currency: currency,
},
CountryCode: common.PtrString(country),
+7 -33
View File
@@ -181,36 +181,12 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
return
}
if s.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items)
invStatus := "success"
if err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...); err != nil {
// The payment is already settled at Klarna (checkout_complete), so the
// order MUST be created. Inventory is best-effort at this point — a
// reservation failure (unstocked / drop-ship / non-tracked items)
// must not block order creation. Log it and flag the grain so
// fulfillment can reconcile.
logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway",
"err", err, "checkoutId", grain.Id.String())
invStatus = "failed"
} else {
// Commit step: successfully decremented stock permanently, now release the temporary cart reservations
if s.reservationService != nil {
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationService.ReleaseForCart(r.Context(), inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
logger.WarnContext(r.Context(), "klarna push: failed to release cart reservation", "sku", item.Sku, "err", err)
}
}
}
}
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: invStatus,
})
}
// Inventory is NOT committed here. Checkout announces a completed sale; the
// order saga emits order.created and the inventory service reacts (commit +
// release the cart hold + emit a level crossing), idempotently. This keeps a
// down inventory service from blocking the revenue path — the payment is
// already settled at Klarna, so the sale is a fact, not a request. See
// docs/inventory-shape.md.
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
PaymentId: orderId,
@@ -266,8 +242,6 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
})
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
@@ -282,7 +256,7 @@ func shouldTrackInventory(item *cart.CartItem) bool {
if item.InventoryTracked {
return true
}
return item.Cgm == "55010"
return false
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
+21 -8
View File
@@ -12,17 +12,17 @@ import (
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
"git.k6n.net/mats/platform/tax"
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
@@ -55,14 +55,14 @@ var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-servic
// selectTaxProvider picks the tax provider from the environment.
// Default is NordicTaxProvider with SE as the default country.
// Set TAX_PROVIDER=static for dev and tests (no country awareness).
func selectTaxProvider() cart.TaxProvider {
func selectTaxProvider() tax.Provider {
switch os.Getenv("TAX_PROVIDER") {
case "static":
return cart.NewStaticTaxProvider()
return tax.NewStatic()
case "nordic":
return cart.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
default:
return cart.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
}
}
@@ -171,15 +171,28 @@ func main() {
}
var orderHandler *AmqpOrderHandler
conn, err := amqp.Dial(amqpUrl)
conn, err := rabbit.Dial(amqpUrl, "cart-checkout")
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
orderHandler = NewAmqpOrderHandler(conn)
orderHandler = NewAmqpOrderHandler(conn.Connection())
if err := orderHandler.DefineQueue(); err != nil {
log.Fatalf("failed to declare order queue: %v", err)
}
// Reconnecting order queue consumer: re-define queue and re-consume on reconnect.
conn.NotifyOnReconnect(func() {
if err := orderHandler.DefineQueue(); err != nil {
log.Printf("checkout: define queue on reconnect: %v", err)
}
})
// Stream applied checkout mutations to the shared mutations exchange
// (routing key mutation.checkout) for the backoffice live feed.
checkoutFeed := actor.NewAmqpListener(conn.Connection(), "checkout", actor.MutationSummary)
checkoutFeed.DefineTopics()
pool.AddListener(checkoutFeed)
syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
syncedServer.inventoryService = inventoryService
syncedServer.reservationService = reservationService
+1
View File
@@ -66,6 +66,7 @@ type OrderLine struct {
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
Location string `json:"location,omitempty"`
}
// CreateOrderResult is the success response from POST /api/orders/from-checkout.
+15 -7
View File
@@ -43,21 +43,29 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
if it == nil {
continue
}
// Carry the per-item store as the inventory commit location; empty when
// no store (commit then falls back to the order country / central stock).
location := ""
if it.StoreId != nil {
location = *it.StoreId
}
lines = append(lines, OrderLine{
Reference: it.Sku,
Sku: it.Sku,
Name: it.Meta.Name,
Quantity: int32(it.Quantity),
UnitPrice: it.Price.IncVat,
// CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
TaxRate: int32(it.Tax / 100),
UnitPrice: it.Price.IncVat.Int64(),
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
// (2500 = 25%), so the rate passes through unchanged.
TaxRate: int32(it.Tax),
Location: location,
})
}
for _, d := range grain.Deliveries {
if d == nil {
continue
}
unitPrice := d.Price.IncVat
unitPrice := d.Price.IncVat.Int64()
if hasFreeShipping {
unitPrice = 0
}
@@ -70,7 +78,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
Name: "Delivery",
Quantity: 1,
UnitPrice: unitPrice,
TaxRate: 25,
TaxRate: 2500, // 25% in basis points
})
}
@@ -82,7 +90,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
}
discountVal := int64(0)
if ap.Discount != nil {
discountVal = ap.Discount.IncVat
discountVal = ap.Discount.IncVat.Int64()
}
if discountVal <= 0 {
continue
@@ -93,7 +101,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
Name: "Promotion: " + ap.Name,
Quantity: 1,
UnitPrice: -discountVal,
TaxRate: 25, // default standard tax rate for promotion discount
TaxRate: 2500, // default standard tax rate (25%) in basis points
})
}
}
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/platform/tax"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
@@ -53,7 +54,7 @@ type CheckoutPoolServer struct {
orderHandler *AmqpOrderHandler
inventoryService *inventory.RedisInventoryService
reservationService *inventory.RedisCartReservationService
taxProvider cart.TaxProvider
taxProvider tax.Provider
}
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer {
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"context"
"log"
"time"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
contract "git.k6n.net/mats/platform/order"
"git.k6n.net/mats/platform/rabbit"
"github.com/redis/go-redis/v9"
)
// commitOrder applies an order.created event to inventory. The sale already
// happened, so this is an unconditional permanent decrement (it may drive stock
// negative — visible oversell, reconciled downstream as backorder), NOT a
// check-and-reserve that could refuse. It then releases the cart's TTL hold and
// emits the resulting level crossing. Idempotent per order via a Redis marker,
// since the bus is at-least-once.
func commitOrder(ctx context.Context, rdb *redis.Client, svc *inventory.RedisInventoryService, resv *inventory.RedisCartReservationService, conn *rabbit.Conn, country string, low int64, c contract.Created) {
if c.OrderID == "" {
return
}
// Idempotency: first writer wins; redelivery is a no-op.
fresh, err := rdb.SetNX(ctx, "invcommit:"+c.OrderID, "1", 30*24*time.Hour).Result()
if err != nil {
log.Printf("inventory: commit idempotency order=%s: %v", c.OrderID, err)
return
}
if !fresh {
return // already committed
}
// Default location for lines that don't carry their own: the order country
// (central stock), falling back to the service's configured country.
defaultLoc := c.Country
if defaultLoc == "" {
defaultLoc = country
}
for _, line := range c.Lines {
if line.SKU == "" || line.Quantity <= 0 {
continue
}
loc := inventory.LocationID(defaultLoc)
if line.Location != "" {
loc = inventory.LocationID(line.Location)
}
sku := inventory.SKU(line.SKU)
pipe := rdb.Pipeline()
if err := svc.DecrementInventory(ctx, pipe, sku, loc, int64(line.Quantity)); err != nil {
log.Printf("inventory: commit decrement sku=%s order=%s: %v", sku, c.OrderID, err)
continue
}
if _, err := pipe.Exec(ctx); err != nil {
log.Printf("inventory: commit exec sku=%s order=%s: %v", sku, c.OrderID, err)
continue
}
// Release the cart's TTL hold now the sale is committed (best-effort —
// it would expire on its own anyway).
if c.CartID != "" && resv != nil {
if err := resv.ReleaseForCart(ctx, sku, loc, inventory.CartID(c.CartID)); err != nil {
log.Printf("inventory: commit release sku=%s cart=%s: %v", sku, c.CartID, err)
}
}
// Emit the level crossing from the new on-hand.
if newQty, err := svc.GetInventory(ctx, sku, loc); err == nil {
emitLevelIfChanged(ctx, rdb, conn, country, low, string(sku), string(loc), newQty)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"log"
"strconv"
"time"
"git.k6n.net/mats/platform/event"
invlevel "git.k6n.net/mats/platform/inventory"
"git.k6n.net/mats/platform/rabbit"
"github.com/redis/go-redis/v9"
)
// emitLevelIfChanged classifies qty into a Level and emits an
// inventory.level_changed bus event only when it differs from the level last
// emitted for this SKU+location (the levelKey marker). Exact quantity never
// leaves the service; only the coarse crossing does. Shared by every write path
// (catalog feed, order.created commit). A nil conn (no bus) makes it a no-op.
func emitLevelIfChanged(ctx context.Context, rdb *redis.Client, conn *rabbit.Conn, country string, low int64, sku, loc string, qty int64) {
if conn == nil {
return
}
lvl := invlevel.LevelFor(qty, low)
// Atomic read-old + write-new of the level marker; redis.Nil means the
// marker didn't exist yet (first observation), which we treat as a crossing.
prev, err := rdb.SetArgs(ctx, levelKey(sku, loc), string(lvl), redis.SetArgs{Get: true}).Result()
if err != nil && err != redis.Nil {
log.Printf("inventory: level marker sku=%s: %v", sku, err)
return
}
if prev == string(lvl) {
return // no threshold crossing — stay off the bus
}
payload, err := invlevel.LevelChanged{SKU: sku, Location: loc, Level: lvl}.Encode()
if err != nil {
log.Printf("inventory: encode level payload sku=%s: %v", sku, err)
return
}
ev := event.Event{
ID: "invlvl-" + sku + "-" + loc + "-" + strconv.FormatInt(time.Now().UnixNano(), 36),
Type: event.InventoryLevelChanged,
Source: "inventory",
Subject: sku,
Payload: payload,
Time: time.Now(),
Meta: map[string]string{"country": country},
}
if err := rabbit.PublishEvent(conn.Connection(), "inventory", ev); err != nil {
log.Printf("inventory: publish level event sku=%s: %v", sku, err)
}
}
+125 -20
View File
@@ -6,14 +6,18 @@ import (
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/event"
orderevent "git.k6n.net/mats/platform/order"
"git.k6n.net/mats/slask-finder/pkg/index"
"git.k6n.net/mats/slask-finder/pkg/messaging"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"git.k6n.net/mats/platform/rabbit"
amqp "github.com/rabbitmq/amqp091-go"
)
@@ -66,6 +70,12 @@ var country = "se"
var redisAddress = "10.10.3.18:6379"
var redisPassword = "slaskredis"
// lowWatermark is the cutoff between "low" and "high" stock for the level
// crossings emitted on the bus (0 < qty <= lowWatermark ⇒ low). Single global
// number for now; per-SKU / per-tenant thresholds can come later behind the
// same inventory.LevelChanged payload.
var lowWatermark int64 = 5
func init() {
// Override redis config from environment variables if set
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
@@ -77,6 +87,19 @@ func init() {
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
country = ctry
}
if v, ok := os.LookupEnv("INVENTORY_LOW_WATERMARK"); ok {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
lowWatermark = n
}
}
}
// levelKey is the Redis key holding the last-emitted Level for a SKU+location.
// It is the crossing-detection marker: the level listener only emits when the
// freshly computed level differs from this stored value, so the bus carries
// crossings, not every quantity tick.
func levelKey(sku, loc string) string {
return "invlevel:" + sku + ":" + loc
}
func main() {
@@ -114,39 +137,121 @@ func main() {
rdb: rdb,
ctx: ctx,
svc: *s,
country: country,
low: lowWatermark,
}
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
if ok {
log.Printf("Connecting to rabbitmq")
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
Properties: amqp.NewConnectionProperties(),
})
//a.conn = conn
conn, err := rabbit.Dial(amqpUrl, "cart-inventory")
if err != nil {
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
}
ch, err := conn.Channel()
if err != nil {
log.Fatalf("Failed to open a channel: %v", err)
}
// items listener
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
wg := &sync.WaitGroup{}
err = index.ForEachRawDataItemInJSONArray(d.Body, func(item *index.RawDataItem) error {
stockhandler.HandleItem(item, wg)
// The catalog-feed handler emits inventory.level_changed crossings
// directly on this connection as it writes stock.
stockhandler.conn = conn
// Reconnecting consumer: re-listen on reconnect.
conn.NotifyOnReconnect(func() {
ch, err := conn.Channel()
if err != nil {
log.Printf("inventory: channel on reconnect: %v", err)
return
}
// Producer side: declare the "inventory" topic exchange we publish
// inventory.level_changed crossings to. Durable + idempotent, so
// re-declaring on each reconnect is safe.
if err := ch.ExchangeDeclare("inventory", "topic", true, false, false, false, nil); err != nil {
log.Printf("inventory: declare inventory exchange: %v", err)
}
// 1) LEGACY raw `catalog.item_changed` wire — payload is a raw item
// JSON array. Consumed by the historical pricewatcher +
// writeradmin item-changed paths. Carries RawDataItem bodies
// with full finder-style metadata (including the stock
// field that the new projection wire does not).
//
// This listener is the one that mutates Redis stock state via
// StockHandler.HandleItem.
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogItemChanged), func(d amqp.Delivery) error {
var ev event.Event
if err := json.Unmarshal(d.Body, &ev); err != nil {
log.Printf("inventory: decode catalog event: %v", err)
return nil
}
if ev.Meta["country"] != "" && ev.Meta["country"] != country {
return nil // not for our country
}
wg := &sync.WaitGroup{}
if err := index.ForEachRawDataItemInJSONArray(ev.Payload, func(item *index.RawDataItem) error {
stockhandler.HandleItem(item, wg)
return nil
}); err != nil {
log.Printf("inventory: apply items: %v", err)
}
wg.Wait()
log.Print("Batch done...")
return err
})
return nil
return nil
}); err != nil {
log.Printf("inventory: listen on reconnect: %v", err)
}
// 2) (REMOVED) `catalog.projection_published` listener — moved to
// the cart service. Per docs/inventory-shape.md, inventory's
// bus role is emit-only (inventory.level_changed crossings back
// to finder on the inventory exchange). The catalog projection
// is read by the cart at checkout-time lookups via its own
// in-memory CatalogCache, not by inventory. Stock state stays
// sourced from the legacy raw wire (block 1 below) until
// inventory itself emits level_changed on a stock mutation.
// 3) order.created → commit stock (permanent decrement), release the
// cart hold, emit the level crossing. This is the async commit
// path: checkout no longer decrements synchronously; it announces
// a completed sale and inventory reacts. Idempotent per order.
if err := rabbit.ListenToPattern(ch, "order", string(event.OrderCreated), func(d amqp.Delivery) error {
var ev event.Event
if err := json.Unmarshal(d.Body, &ev); err != nil {
log.Printf("inventory: decode order event: %v", err)
return nil
}
if target := countryForEvent(&ev); target != "" && target != country {
return nil // not for our country/tenant
}
created, err := orderevent.Decode(ev.Payload)
if err != nil {
log.Printf("inventory: decode order.created: %v", err)
return nil
}
commitOrder(ctx, rdb, s, r, conn, country, lowWatermark, created)
return nil
}); err != nil {
log.Printf("inventory: listen order.created on reconnect: %v", err)
}
})
if err != nil {
log.Fatalf("Failed to listen to item_added topic: %v", err)
}
}
// Start HTTP server
log.Println("Starting HTTP server on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
// countryForEvent extracts the best-available country/tenant identifier
// from an envelope. Lookup order: Meta["country"], Meta["tenant"], then
// the Source prefix "catalog:<x>". An empty string means "no decoration" —
// callers treat that as "for everyone" (same as the legacy listener).
func countryForEvent(ev *event.Event) string {
if t := strings.TrimSpace(ev.Meta["country"]); t != "" {
return t
}
if t := strings.TrimSpace(ev.Meta["tenant"]); t != "" {
return t
}
if strings.HasPrefix(ev.Source, "catalog:") {
if t := strings.TrimSpace(strings.TrimPrefix(ev.Source, "catalog:")); t != "" {
return t
}
}
return ""
}
+16 -9
View File
@@ -6,6 +6,7 @@ import (
"sync"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/rabbit"
"git.k6n.net/mats/slask-finder/pkg/types"
"github.com/redis/go-redis/v9"
)
@@ -15,12 +16,17 @@ type StockHandler struct {
ctx context.Context
svc inventory.RedisInventoryService
MainStockLocationID inventory.LocationID
// Bus producer config. conn is nil when RABBIT_HOST is unset (no bus); the
// handler still updates Redis and just skips emitting.
conn *rabbit.Conn
country string
low int64
}
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
wg.Go(func() {
ctx := s.ctx
pipe := s.rdb.Pipeline()
centralStock, ok := item.GetNumberFieldValue("inStock")
if !ok {
centralStock = 0
@@ -28,16 +34,17 @@ func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
sku, ok := item.GetStringFieldValue("sku")
if !ok {
log.Printf("unable to parse central stock for item: sku missing")
centralStock = 0
} else {
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
return
}
// for id, value := range item.GetStock() {
// s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), inventory.LocationID(id), int64(value))
// }
_, err := pipe.Exec(ctx)
if err != nil {
// One linear pass: write the authoritative quantity, then derive and
// emit the level crossing from the same value. No Redis pub/sub
// round-trip — Redis stays the internal store, the bus carries levels.
pipe := s.rdb.Pipeline()
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
if _, err := pipe.Exec(ctx); err != nil {
log.Printf("unable to update stock: %v", err)
return
}
emitLevelIfChanged(ctx, s.rdb, s.conn, s.country, s.low, sku, string(s.MainStockLocationID), int64(centralStock))
})
}
+40 -39
View File
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"git.k6n.net/mats/platform/rabbit"
amqp "github.com/rabbitmq/amqp091-go"
)
@@ -25,7 +26,7 @@ func newRabbitPublisher(url string) *rabbitPublisher {
}
func (p *rabbitPublisher) connect() error {
conn, err := amqp.Dial(p.url)
conn, err := rabbit.Connect(p.url, "cart-order-publisher")
if err != nil {
return err
}
@@ -91,50 +92,50 @@ func ingestOrderFromQueue(ctx context.Context, s *server, body []byte) error {
}
// startOrderIngest connects to AMQP and consumes "order-queue" until ctx is
// done.
// done. The connection is reconnecting — on broker blips the caller re-declares
// the queue and re-consumes automatically.
func startOrderIngest(ctx context.Context, amqpURL string, s *server) {
conn, err := amqp.Dial(amqpURL)
conn, err := rabbit.Dial(amqpURL, "cart-order-ingest")
if err != nil {
s.logger.Warn("order ingest disabled: amqp dial failed", "err", err)
return
}
ch, err := conn.Channel()
if err != nil {
s.logger.Warn("order ingest disabled: channel failed", "err", err)
_ = conn.Close()
return
}
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest disabled: queue declare failed", "err", err)
_ = ch.Close()
_ = conn.Close()
return
}
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest disabled: consume failed", "err", err)
_ = ch.Close()
_ = conn.Close()
return
}
s.logger.Info("ingesting orders from order-queue")
go func() {
defer conn.Close()
defer ch.Close()
for {
select {
case <-ctx.Done():
return
case d, ok := <-msgs:
if !ok {
s.logger.Warn("order ingest: channel closed")
// Reconnecting consumer: re-declare queue and re-consume on reconnect.
conn.NotifyOnReconnect(func() {
ch, err := conn.Channel()
if err != nil {
s.logger.Warn("order ingest: channel on reconnect", "err", err)
return
}
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest: queue declare on reconnect", "err", err)
_ = ch.Close()
return
}
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest: consume on reconnect", "err", err)
_ = ch.Close()
return
}
s.logger.Info("order ingest: consuming from order-queue (reconnect)")
go func() {
defer ch.Close()
for {
select {
case <-ctx.Done():
return
}
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
s.logger.Error("order queue ingest failed", "err", err)
case d, ok := <-msgs:
if !ok {
s.logger.Warn("order ingest: channel closed (will reconnect)")
return
}
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
s.logger.Error("order queue ingest failed", "err", err)
}
}
}
}
}()
}()
})
}
+80 -10
View File
@@ -66,9 +66,33 @@ func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderI
return id, g, true
}
// checkIdempotency checks if the request is an idempotent retry.
// It returns (idemKey, exists, unlockFn).
// If exists is true, the caller should write the current order grain status and return immediately.
// If exists is false, the caller must defer unlockFn() and continue.
func (s *server) checkIdempotency(w http.ResponseWriter, r *http.Request, id order.OrderId) (string, bool, func()) {
idemKey := r.Header.Get("Idempotency-Key")
if idemKey == "" {
return "", false, func() {}
}
unlock := s.idem.Lock(idemKey)
if _, ok := s.idem.Get(idemKey); ok {
unlock() // Release lock immediately since we are returning cached response
s.logger.Info("idempotency hit for lifecycle endpoint", "key", idemKey, "orderId", id.String())
grain, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return idemKey, true, nil
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
return idemKey, true, nil
}
return idemKey, false, unlock
}
// applyAndRespond applies a mutation and returns the updated order. A rejected
// state transition (handler error) maps to 409 Conflict.
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message) {
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message, idemKey string) {
res, err := s.applier.Apply(r.Context(), uint64(id), msg)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
@@ -80,6 +104,11 @@ func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id orde
return
}
}
if idemKey != "" {
if err := s.idem.Put(idemKey, uint64(id)); err != nil {
s.logger.Error("idempotency record failed", "key", idemKey, "orderId", id.String(), "err", err)
}
}
grain, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
@@ -105,6 +134,12 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req fulfillReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -182,7 +217,7 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg)
s.applyAndRespond(w, r, id, msg, idemKey)
}
type exchangeReq struct {
@@ -208,6 +243,12 @@ func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req exchangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -236,7 +277,7 @@ func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
TotalTax: l.TotalTax,
})
}
s.applyAndRespond(w, r, id, msg)
s.applyAndRespond(w, r, id, msg, idemKey)
}
type editDetailsReq struct {
@@ -250,6 +291,12 @@ func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req editDetailsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -261,16 +308,21 @@ func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
ShippingPrice: req.ShippingPrice,
AtMs: nowMs(),
}
s.applyAndRespond(w, r, id, msg)
s.applyAndRespond(w, r, id, msg, idemKey)
}
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()})
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()}, idemKey)
}
func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
@@ -278,11 +330,17 @@ func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req struct {
Reason string `json:"reason"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()})
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()}, idemKey)
}
type returnReq struct {
@@ -298,6 +356,12 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req returnReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -308,7 +372,7 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg)
s.applyAndRespond(w, r, id, msg, idemKey)
}
func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
@@ -316,13 +380,19 @@ func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req struct {
Amount int64 `json:"amount"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
amount := req.Amount
if amount <= 0 {
amount = g.CapturedAmount - g.RefundedAmount // default: full remaining
amount = (g.CapturedAmount - g.RefundedAmount).Int64() // default: full remaining
}
captureRef := capturedRef(g)
ref, err := s.provider.Refund(r.Context(), captureRef, amount)
@@ -335,7 +405,7 @@ func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
Amount: ref.Amount,
Reference: ref.Reference,
AtMs: nowMs(),
})
}, idemKey)
}
// --- saga / flow admin (a) ------------------------------------------------
+3 -1
View File
@@ -10,6 +10,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/money"
)
// fromCheckoutReq is the payload the checkout service sends to create an order
@@ -164,7 +165,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
var total, totalTax int64
for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity)
lineTax := order.ComputeTax(lineTotal, int(l.TaxRate))
lineTax := order.ComputeTax(money.Cents(lineTotal), int(l.TaxRate)).Int64()
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{
@@ -176,6 +177,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
TaxRate: l.TaxRate,
TotalAmount: lineTotal,
TotalTax: lineTax,
Location: l.Location,
})
}
po.TotalAmount = total
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
func TestLifecycleIdempotencyRefund(t *testing.T) {
s := testServer(t)
s.provider = order.NewMockProvider()
ctx := context.Background()
// Ingest an order to get it into Captured status
body := []byte(`{
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
}`)
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("ingest: %v", err)
}
orderID, ok := s.idem.Get("checkout-K-1")
if !ok {
t.Fatalf("expected order ID to be stored in idempotency cache")
}
// ── First refund request (with idempotency key) ────────────────────────
reqBody := []byte(`{"amount": 5000}`)
req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req1.Header.Set("Idempotency-Key", "refund-key-123")
req1.SetPathValue("id", order.OrderId(orderID).String())
w1 := httptest.NewRecorder()
s.handleRefund(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first refund status = %d, want 200", w1.Code)
}
g1, err := s.applier.Get(ctx, orderID)
if err != nil {
t.Fatal(err)
}
if g1.RefundedAmount != 5000 {
t.Fatalf("refunded amount after first call = %d, want 5000", g1.RefundedAmount)
}
// ── Second refund request (with the same idempotency key) ──────────────
req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req2.Header.Set("Idempotency-Key", "refund-key-123")
req2.SetPathValue("id", order.OrderId(orderID).String())
w2 := httptest.NewRecorder()
s.handleRefund(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second refund status = %d, want 200", w2.Code)
}
g2, err := s.applier.Get(ctx, orderID)
if err != nil {
t.Fatal(err)
}
// It should NOT have refunded another 5000 (total refunded remains 5000)
if g2.RefundedAmount != 5000 {
t.Fatalf("refunded amount after second call = %d, want 5000 (idempotency hit failed, did double refund)", g2.RefundedAmount)
}
}
func TestLifecycleWithoutIdempotencyRefund(t *testing.T) {
s := testServer(t)
s.provider = order.NewMockProvider()
ctx := context.Background()
// Ingest an order to get it into Captured status
body := []byte(`{
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
}`)
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("ingest: %v", err)
}
orderID, ok := s.idem.Get("checkout-K-1")
if !ok {
t.Fatalf("expected order ID to be stored in idempotency cache")
}
// First refund request (no idempotency key)
reqBody := []byte(`{"amount": 5000}`)
req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req1.SetPathValue("id", order.OrderId(orderID).String())
w1 := httptest.NewRecorder()
s.handleRefund(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first refund status = %d, want 200", w1.Code)
}
// Second refund request (no idempotency key)
req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req2.SetPathValue("id", order.OrderId(orderID).String())
w2 := httptest.NewRecorder()
s.handleRefund(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second refund status = %d, want 200", w2.Code)
}
g2, err := s.applier.Get(ctx, orderID)
if err != nil {
t.Fatal(err)
}
// Without idempotency, it SHOULD have refunded twice (total 10000)
if g2.RefundedAmount != 10000 {
t.Fatalf("refunded amount after second call without idempotency = %d, want 10000", g2.RefundedAmount)
}
}
+39 -10
View File
@@ -28,6 +28,8 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
"git.k6n.net/mats/platform/tax"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
@@ -42,7 +44,7 @@ type server struct {
freg *flow.Registry
flows *flowStore
provider order.PaymentProvider
taxProvider order.TaxProvider
taxProvider tax.Provider
defaultFlow string
dataDir string
idem *idempotency.Store
@@ -112,11 +114,37 @@ func main() {
}
go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger)
emitPub = box
// Stream applied order mutations to the shared mutations exchange (routing
// key mutation.order) for the backoffice live feed. Best-effort, separate
// from the durable outbox above.
if conn, derr := rabbit.Dial(amqpURL, "order"); derr != nil {
logger.Warn("order: mutation feed disabled", "err", derr)
} else {
feed := actor.NewAmqpListener(conn.Connection(), "order", actor.MutationSummary)
feed.DefineTopics()
pool.AddListener(feed)
// Declare the "order" topic exchange the outbox relay publishes
// order.created to, so a publish can't hit a missing exchange before
// a consumer declares it. Durable + idempotent.
if ch, cerr := conn.Channel(); cerr != nil {
logger.Warn("order: declare order exchange: channel", "err", cerr)
} else {
if eerr := ch.ExchangeDeclare("order", "topic", true, false, false, false, nil); eerr != nil {
logger.Warn("order: declare order exchange", "err", eerr)
}
_ = ch.Close()
}
}
}
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterEmitHook(freg, emitPub)
// order.created domain event → durable outbox → "order" topic exchange.
// Reactors: inventory commit, fulfillment allocation.
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
engine := flow.NewEngine(freg, logger)
def, err := order.EmbeddedFlow("place-and-pay")
@@ -248,8 +276,9 @@ type lineReq struct {
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
TaxRate int32 `json:"taxRate"` // percent
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%)
Location string `json:"location,omitempty"` // inventory commit location / store id
}
type checkoutReq struct {
@@ -343,7 +372,7 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
var total, totalTax int64
for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity)
lineTax := s.taxProvider.ComputeTax(lineTotal, int(l.TaxRate))
lineTax := s.taxProvider.Compute(lineTotal, int(l.TaxRate))
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{
@@ -442,8 +471,8 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
OrderId: order.OrderId(raw).String(),
Reference: g.OrderReference,
Status: g.Status,
TotalAmount: g.TotalAmount,
CapturedAmount: g.CapturedAmount,
TotalAmount: g.TotalAmount.Int64(),
CapturedAmount: g.CapturedAmount.Int64(),
Currency: g.Currency,
PlacedAt: g.PlacedAt,
})
@@ -457,15 +486,15 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
// NordicTaxProvider with SE as the default country (matching the current
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
// (no country awareness).
func selectTaxProvider() order.TaxProvider {
func selectTaxProvider() tax.Provider {
provider := os.Getenv("TAX_PROVIDER")
switch provider {
case "static":
return order.NewStaticTaxProvider()
return tax.NewStatic()
case "nordic":
return order.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
default:
return order.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
}
}
+15
View File
@@ -20,6 +20,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
@@ -126,6 +127,20 @@ func main() {
log.Fatalf("Error creating profile pool: %v\n", err)
}
// Stream applied profile mutations to the shared mutations exchange (routing
// key mutation.profile) for the backoffice live feed. Best-effort: disabled
// when AMQP_URL is unset or the broker is unreachable.
if amqpURL := os.Getenv("AMQP_URL"); amqpURL != "" {
if conn, derr := rabbit.Dial(amqpURL, "cart-profile"); derr != nil {
log.Printf("profile: AMQP connect failed, mutation feed disabled: %v", derr)
} else {
feed := actor.NewAmqpListener(conn.Connection(), "profile", actor.MutationSummary)
feed.DefineTopics()
pool.AddListener(feed)
log.Printf("profile: mutation feed enabled (mutation.profile)")
}
}
// Clustering control plane: gRPC server on :1337 serves peer pools'
// ownership announcements and remote Apply/Get calls; UseDiscovery watches
// for sibling profile pods (label actor-pool=profile) and wires them into the
File diff suppressed because it is too large Load Diff