all the refactor
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user