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

This commit is contained in:
2026-06-27 19:49:00 +02:00
parent 492f54ff45
commit 528c59bfd3
67 changed files with 3618 additions and 1031 deletions
+13 -3
View File
@@ -12,6 +12,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
@@ -129,7 +130,7 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
// Adyen analogue of the Klarna push. Create the event-sourced
// order now (mirrors KlarnaPushHandler). Idempotent on
// "checkout-{checkoutId}", so a retried CAPTURE won't duplicate.
if isSuccess && s.orderClient != nil {
if isSuccess {
s.createAdyenOrder(r.Context(), *checkoutId, item)
}
@@ -231,12 +232,21 @@ func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId ca
return
}
// Reserve inventory once, guarded by the grain flag so a retried CAPTURE
// does not decrement stock twice. (Same as the Klarna path.)
if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved {
if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil {
log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr)
} else {
// Commit step: release cart reservation since we permanently decremented stock
if s.reservationService != nil {
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
log.Printf("from-checkout: failed to release cart reservation for %s: %v", item.Sku, err)
}
}
}
s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: "success",
+73 -13
View File
@@ -64,7 +64,9 @@ type CheckoutMeta struct {
//
// If you later need to support different tax rates per line, you can extend
// CartItem / Delivery to expose that data and propagate it here.
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
// tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for
// delivery lines instead of the hardcoded 2500 (25 % as CartItem format).
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
@@ -106,12 +108,32 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
})
}
hasFreeShipping := false
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
// Delivery lines — use the configured default tax rate for the purchase country.
defaultKlarnaRate := defaultKlarnaTaxRate(tp, country)
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
if d == nil {
continue
}
unitPrice := d.Price.IncVat
taxAmount := d.Price.TotalVat()
if hasFreeShipping {
unitPrice = 0
taxAmount = 0
}
if unitPrice <= 0 && !hasFreeShipping {
continue
}
//total.Add(d.Price)
@@ -120,11 +142,11 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price.IncVat),
TaxRate: 2500,
UnitPrice: int(unitPrice),
TaxRate: defaultKlarnaRate,
QuantityUnit: "st",
TotalAmount: int(d.Price.IncVat),
TotalTaxAmount: int(d.Price.TotalVat()),
TotalAmount: int(unitPrice),
TotalTaxAmount: int(taxAmount),
})
}
@@ -154,6 +176,24 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
return payload, order, nil
}
// defaultKlarnaTaxRate returns the default tax rate for the purchase country, in Klarna
// format (percent x 100, e.g. 2500 = 25.00 %). When tp is nil, falls back to 2500 (25 %).
func defaultKlarnaTaxRate(tp cart.TaxProvider, country string) int {
if tp == nil {
return 2500
}
return tp.DefaultTaxRate(country) * 100
}
// defaultAdyenTaxRate returns the default tax rate for the purchase country, in Adyen
// format (raw percent, e.g. 25 = 25 %). When tp is nil, falls back to 25.
func defaultAdyenTaxRate(tp cart.TaxProvider, country string) int64 {
if tp == nil {
return 25
}
return int64(tp.DefaultTaxRate(country))
}
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
host := getOriginalHost(r)
country := getCountryFromHost(host)
@@ -171,7 +211,7 @@ func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
}
}
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
if grain == nil {
return nil, fmt.Errorf("nil grain")
}
@@ -204,20 +244,40 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
TaxPercentage: common.PtrInt64(int64(it.Tax)),
})
}
hasFreeShipping := false
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines
// Delivery lines — use the configured default tax rate for the purchase country.
defaultAdyenRate := defaultAdyenTaxRate(tp, country)
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
if d == nil {
continue
}
amountIncTax := d.Price.IncVat
amountExTax := d.Price.ValueExVat()
if hasFreeShipping {
amountIncTax = 0
amountExTax = 0
}
if amountIncTax <= 0 && !hasFreeShipping {
continue
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(1),
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
AmountIncludingTax: common.PtrInt64(amountIncTax),
Description: common.PtrString("Delivery"),
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
TaxPercentage: common.PtrInt64(25),
AmountExcludingTax: common.PtrInt64(amountExTax),
TaxPercentage: common.PtrInt64(defaultAdyenRate),
})
}
+3 -1
View File
@@ -24,7 +24,9 @@ func GetDiscovery() discovery.Discovery {
log.Fatalf("Error creating client: %v\n", err)
}
timeout := int64(30)
return discovery.NewK8sDiscovery(client, v1.ListOptions{
// Scope discovery to this pod's namespace so it only needs a namespaced Role
// (pods: get/list/watch), not a cluster-wide ClusterRole.
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
LabelSelector: "actor-pool=checkout",
TimeoutSeconds: &timeout,
})
+29 -69
View File
@@ -193,6 +193,18 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway",
"err", err, "checkoutId", grain.Id.String())
invStatus = "failed"
} else {
// Commit step: successfully decremented stock permanently, now release the temporary cart reservations
if s.reservationService != nil {
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationService.ReleaseForCart(r.Context(), inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
logger.WarnContext(r.Context(), "klarna push: failed to release cart reservation", "sku", item.Sku, "err", err)
}
}
}
}
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
@@ -209,22 +221,12 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
CompletedAt: timestamppb.Now(),
})
// ── Create the event-sourced order grain (dual-write with AMQP) ────
if s.orderClient != nil {
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
// Non-fatal: the checkout grain is updated, the order can be
// created on retry (idempotency key guards against duplicates).
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
"err", orderErr, "checkoutId", grain.Id.String())
}
}
// ── Dual-write: AMQP order-queue for legacy consumers ───────────────
if s.orderHandler != nil {
legacyBody, _ := buildLegacyOrderJSON(grain, order, "klarna", orderId)
if pubErr := s.orderHandler.OrderCompleted(legacyBody); pubErr != nil {
logger.WarnContext(r.Context(), "order-queue publish failed", "err", pubErr)
}
// ── Create the event-sourced order grain (HTTP with AMQP fallback) ────
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
// Non-fatal: the checkout grain is updated, the order can be
// created on retry (idempotency key guards against duplicates).
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
"err", orderErr, "checkoutId", grain.Id.String())
}
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
@@ -264,59 +266,7 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
})
}
// buildLegacyOrderJSON builds the go-order-manager compatible JSON for AMQP
// dual-write, so existing downstream consumers on the order-queue still receive
// order events during the migration period.
func buildLegacyOrderJSON(grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string, orderId string) ([]byte, error) {
if grain.CartState == nil {
return nil, fmt.Errorf("buildLegacyOrderJSON: checkout %s has no cart state", grain.Id)
}
type legacyLine struct {
Reference string `json:"reference"`
Name string `json:"name"`
Quantity int `json:"quantity"`
UnitPrice int `json:"unit_price"`
TaxRate int `json:"tax_rate"`
TotalAmount int `json:"total_amount"`
TotalTaxAmount int `json:"total_tax_amount"`
}
type legacyOrder struct {
ID string `json:"order_id"`
PurchaseCountry string `json:"purchase_country"`
PurchaseCurrency string `json:"purchase_currency"`
Locale string `json:"locale"`
OrderAmount int `json:"order_amount"`
OrderTaxAmount int `json:"order_tax_amount"`
OrderLines []legacyLine `json:"order_lines"`
}
lines := make([]legacyLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lines = append(lines, legacyLine{
Reference: it.Sku,
Name: it.Meta.Name,
Quantity: int(it.Quantity),
UnitPrice: int(it.Price.IncVat),
TaxRate: it.Tax,
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
})
}
lo := legacyOrder{
ID: orderId,
PurchaseCountry: klarnaOrder.PurchaseCountry,
PurchaseCurrency: klarnaOrder.PurchaseCurrency,
Locale: klarnaOrder.Locale,
OrderAmount: klarnaOrder.OrderAmount,
OrderTaxAmount: klarnaOrder.OrderTaxAmount,
OrderLines: lines,
}
return json.Marshal(lo)
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
@@ -325,10 +275,20 @@ func getLocationId(item *cart.CartItem) inventory.LocationID {
return inventory.LocationID(*item.StoreId)
}
func shouldTrackInventory(item *cart.CartItem) bool {
if item.DropShip {
return false
}
if item.InventoryTracked {
return true
}
return item.Cgm == "55010"
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
if item == nil || !shouldTrackInventory(item) {
continue
}
requests = append(requests, inventory.ReserveRequest{
+40 -5
View File
@@ -12,14 +12,16 @@ import (
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
@@ -49,6 +51,27 @@ var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
// selectTaxProvider picks the tax provider from the environment.
// Default is NordicTaxProvider with SE as the default country.
// Set TAX_PROVIDER=static for dev and tests (no country awareness).
func selectTaxProvider() cart.TaxProvider {
switch os.Getenv("TAX_PROVIDER") {
case "static":
return cart.NewStaticTaxProvider()
case "nordic":
return cart.NewNordicTaxProvider(envOrDefault("TAX_DEFAULT_COUNTRY", "SE"))
default:
return cart.NewNordicTaxProvider(envOrDefault("TAX_DEFAULT_COUNTRY", "SE"))
}
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPCheckoutSigner() *ucp.SigningConfig {
@@ -90,12 +113,17 @@ func main() {
log.Fatalf("Error creating inventory service: %v\n", err)
}
reservationService, err := inventory.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Error creating reservation service: %v\n", err)
}
checkoutDir := os.Getenv("CHECKOUT_DIR")
if checkoutDir == "" {
checkoutDir = "data"
}
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
var syncedServer *CheckoutPoolServer
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
MutationRegistry: reg,
Storage: diskStorage,
@@ -113,6 +141,11 @@ func main() {
return ret, nil
},
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
ctx := context.Background()
state, err := grain.GetCurrentState()
if err == nil && state != nil {
syncedServer.releaseCartReservations(ctx, state)
}
return nil
},
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
@@ -153,8 +186,10 @@ func main() {
log.Fatalf("failed to declare order queue: %v", err)
}
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
syncedServer.inventoryService = inventoryService
syncedServer.reservationService = reservationService
syncedServer.taxProvider = selectTaxProvider()
mux := http.NewServeMux()
debugMux := http.NewServeMux()
@@ -174,7 +209,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := setupOTelSDK(ctx)
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
+87 -15
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
@@ -27,6 +28,16 @@ type settledPayment struct {
// selections into from-checkout order lines. Shared by every provider so the
// order payload is built the same way no matter who settled the payment.
func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
hasFreeShipping := false
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
for _, it := range grain.CartState.Items {
if it == nil {
@@ -38,11 +49,19 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
Name: it.Meta.Name,
Quantity: int32(it.Quantity),
UnitPrice: it.Price.IncVat,
TaxRate: int32(it.Tax),
// CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
TaxRate: int32(it.Tax / 100),
})
}
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
if d == nil {
continue
}
unitPrice := d.Price.IncVat
if hasFreeShipping {
unitPrice = 0
}
if unitPrice <= 0 && !hasFreeShipping {
continue
}
lines = append(lines, OrderLine{
@@ -50,10 +69,35 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
Sku: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: d.Price.IncVat,
TaxRate: 2500,
UnitPrice: unitPrice,
TaxRate: 25,
})
}
// Reflected applied promotions as negative discount lines
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Pending {
continue
}
discountVal := int64(0)
if ap.Discount != nil {
discountVal = ap.Discount.IncVat
}
if discountVal <= 0 {
continue
}
lines = append(lines, OrderLine{
Reference: "promo-" + ap.PromotionId,
Sku: "promo-" + ap.PromotionId,
Name: "Promotion: " + ap.Name,
Quantity: 1,
UnitPrice: -discountVal,
TaxRate: 25, // default standard tax rate for promotion discount
})
}
}
return lines
}
@@ -64,8 +108,10 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
//
// It is idempotent: the order service dedupes on "checkout-{checkoutId}", so
// repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the
// existing order rather than creating a duplicate. Callers should treat a
// non-nil error as non-fatal and retry on the next callback.
// existing order rather than creating a duplicate.
//
// If the HTTP call fails or is not configured, it falls back to publishing the
// exact same payload to RabbitMQ via s.orderHandler.OrderCompleted(body).
func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) {
if grain.CartState == nil {
return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id)
@@ -96,15 +142,41 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
req.Payment.Reference = pay.Reference
req.Payment.Amount = pay.Amount
result, err := s.orderClient.CreateOrder(ctx, req, "")
if err != nil {
return nil, err
var createErr error
if s.orderClient != nil {
result, err := s.orderClient.CreateOrder(ctx, req, "")
if err == nil {
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
OrderId: result.OrderId,
Status: "completed",
CreatedAt: timestamppb.Now(),
})
return result, nil
}
createErr = err
logger.WarnContext(ctx, "from-checkout HTTP call failed; falling back to AMQP", "err", err, "checkoutId", grain.Id.String())
}
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
OrderId: result.OrderId,
Status: "completed",
CreatedAt: timestamppb.Now(),
})
return result, nil
// Fallback to AMQP
if s.orderHandler != nil {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal order fallback: %w", err)
}
if pubErr := s.orderHandler.OrderCompleted(body); pubErr != nil {
if createErr != nil {
return nil, fmt.Errorf("AMQP fallback failed: %w (HTTP error: %v)", pubErr, createErr)
}
return nil, fmt.Errorf("AMQP fallback failed: %w", pubErr)
}
logger.InfoContext(ctx, "order request published to AMQP successfully as fallback", "checkoutId", grain.Id.String())
return &CreateOrderResult{
OrderId: "queued-" + grain.Id.String(),
}, nil
}
if createErr != nil {
return nil, createErr
}
return nil, fmt.Errorf("neither order client nor AMQP order handler configured")
}
-117
View File
@@ -1,117 +0,0 @@
package main
import (
"context"
"errors"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/trace"
)
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
// If it does not return an error, make sure to call shutdown for proper cleanup.
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
var shutdownFuncs []func(context.Context) error
var err error
// shutdown calls cleanup functions registered via shutdownFuncs.
// The errors from the calls are joined.
// Each registered cleanup will be invoked once.
shutdown := func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
// Set up propagator.
prop := newPropagator()
otel.SetTextMapPropagator(prop)
// Set up trace provider.
tracerProvider, err := newTracerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
// Set up meter provider.
meterProvider, err := newMeterProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
// Set up logger provider.
loggerProvider, err := newLoggerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider)
return shutdown, err
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
func newTracerProvider() (*trace.TracerProvider, error) {
traceExporter, err := otlptracegrpc.New(context.Background())
if err != nil {
return nil, err
}
tracerProvider := trace.NewTracerProvider(
trace.WithBatcher(traceExporter,
// Default is 5s. Set to 1s for demonstrative purposes.
trace.WithBatchTimeout(time.Second)),
)
return tracerProvider, nil
}
func newMeterProvider() (*metric.MeterProvider, error) {
exporter, err := otlpmetricgrpc.New(context.Background())
if err != nil {
return nil, err
}
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
return provider, nil
}
func newLoggerProvider() (*log.LoggerProvider, error) {
logExporter, err := otlploggrpc.New(context.Background())
if err != nil {
return nil, err
}
loggerProvider := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExporter)),
)
return loggerProvider, nil
}
+27 -7
View File
@@ -51,7 +51,9 @@ type CheckoutPoolServer struct {
cartClient *CartClient
orderClient *OrderClient
orderHandler *AmqpOrderHandler
inventoryService *inventory.RedisInventoryService
inventoryService *inventory.RedisInventoryService
reservationService *inventory.RedisCartReservationService
taxProvider cart.TaxProvider
}
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer {
@@ -207,6 +209,10 @@ func (s *CheckoutPoolServer) CancelPaymentHandler(w http.ResponseWriter, r *http
return err
}
if grain, gerr := s.Get(r.Context(), uint64(checkoutId)); gerr == nil && grain != nil {
s.releaseCartReservations(r.Context(), grain)
}
return s.WriteResult(w, result)
}
@@ -295,7 +301,7 @@ func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *chec
meta := GetCheckoutMetaFromRequest(r)
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
payload, _, err := BuildCheckoutOrderPayload(grain, meta, s.taxProvider)
if err != nil {
return nil, err
}
@@ -431,7 +437,7 @@ func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.
switch payload.Provider {
case "adyen":
meta := GetCheckoutMetaFromRequest(r)
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
sessionData, err := BuildAdyenCheckoutSession(grain, meta, s.taxProvider)
if err != nil {
logger.Error("unable to build adyen session", "error", err)
return err
@@ -536,10 +542,10 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler)))
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
@@ -547,3 +553,17 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
}
func (s *CheckoutPoolServer) releaseCartReservations(ctx context.Context, grain *checkout.CheckoutGrain) {
if s.reservationService == nil || grain.CartState == nil {
return
}
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
logger.WarnContext(ctx, "failed to release cart reservation", "sku", item.Sku, "err", err)
}
}
}