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 -6
View File
@@ -54,6 +54,8 @@ type CartItem struct {
OrderReference string `json:"orderReference,omitempty"`
IsSubscribed bool `json:"isSubscribed,omitempty"`
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
InventoryTracked bool `json:"inventoryTracked,omitempty"`
DropShip bool `json:"dropShip,omitempty"`
// Extra holds arbitrary dynamic product data. Its keys are flattened onto
// the item object in JSON (see cart_item_json.go), so they are returned
@@ -152,12 +154,13 @@ type CartGrain struct {
}
type Voucher struct {
Code string `json:"code"`
Applied bool `json:"applied"`
Rules []string `json:"rules"`
Description string `json:"description,omitempty"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
Code string `json:"code"`
Applied bool `json:"applied"`
Rules []string `json:"rules"`
Description string `json:"description,omitempty"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
BypassedByPromotions bool `json:"-"`
}
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
@@ -298,6 +301,10 @@ func (c *CartGrain) UpdateTotals() {
_, ok := voucher.AppliesTo(c)
voucher.Applied = false
if ok {
if voucher.BypassedByPromotions {
voucher.Applied = true
continue
}
value := NewPriceFromIncVat(voucher.Value, 25)
if c.TotalPrice.IncVat <= value.IncVat {
// don't apply discounts to more than the total price
+6
View File
@@ -51,6 +51,12 @@ func (c *CartMutationContext) UseReservations(item *CartItem) bool {
if item.ReservationEndTime != nil {
return true
}
if item.DropShip {
return false
}
if item.InventoryTracked {
return true
}
return item.Cgm == "55010"
}
+4
View File
@@ -76,6 +76,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
}
existing.Quantity += uint16(m.Quantity)
existing.Stock = uint16(m.Stock)
existing.InventoryTracked = m.InventoryTracked
existing.DropShip = m.DropShip
// If existing had nil store but new has one, adopt it.
if existing.StoreId == nil && m.StoreId != nil {
existing.StoreId = m.StoreId
@@ -144,6 +146,8 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
Extra: decodeExtra(m.ExtraJson),
CustomFields: m.CustomFields,
InventoryTracked: m.InventoryTracked,
DropShip: m.DropShip,
}
if needsReservation && c.UseReservations(cartItem) {
+1 -1
View File
@@ -50,7 +50,7 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
if m.Quantity <= 0 {
// Remove the item
itemToRemove := g.Items[foundIndex]
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.Before(time.Now()) {
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.After(time.Now()) {
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
if err != nil {
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
+58
View File
@@ -0,0 +1,58 @@
package cart
// TaxProvider computes taxes for orders and line items.
// It mirrors the PaymentProvider and ShippingProvider seam patterns: one
// interface, interchangeable implementations.
//
// All tax rates are expressed as raw percent (e.g. 25 = 25 %). This matches the
// OrderLine.tax_rate proto convention.
type TaxProvider interface {
// Name returns the provider name for identification (logging, metrics).
Name() string
// ComputeTax computes the tax portion from a gross (inc-VAT) total and tax
// rate. Both total and return value are in minor currency units (ore).
// taxRate is expressed as raw percent (e.g. 25 = 25 %).
//
// Formula: tax = total x rate / (100 + rate)
//
// When taxRate <= 0 the result is 0 (untaxed / zero-rated items).
ComputeTax(total int64, taxRate int) int64
// DefaultTaxRate returns the default VAT rate for a country, expressed as
// raw percent (e.g. 25 = 25 %). Used for deliveries and items where no
// explicit rate was recorded at add-to-cart time.
//
// Return 0 if the country is unknown or untaxed.
DefaultTaxRate(country string) int
}
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
// taxRate is raw percent (e.g. 25 = 25 %). When taxRate <= 0 the result is 0.
//
// This differs from GetTaxAmount which uses the CartItem convention
// (percent x 100, e.g. 2500 = 25.00 %) with formula total x rate / (10000 + rate).
func ComputeTax(total int64, taxRate int) int64 {
if taxRate <= 0 {
return 0
}
return total * int64(taxRate) / int64(100+taxRate)
}
// StaticTaxProvider is a stateless, always-available TaxProvider that simply
// runs the formula without any country-specific rates. Use for local dev,
// tests, and as a no-dependency default.
type StaticTaxProvider struct{}
var _ TaxProvider = (*StaticTaxProvider)(nil)
func NewStaticTaxProvider() *StaticTaxProvider { return &StaticTaxProvider{} }
func (p *StaticTaxProvider) Name() string { return "static" }
func (p *StaticTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// DefaultTaxRate returns 0 for any country (no awareness).
func (p *StaticTaxProvider) DefaultTaxRate(_ string) int { return 0 }
+55
View File
@@ -0,0 +1,55 @@
package cart
// NordicTaxProvider implements TaxProvider for the Nordic countries
// (SE, NO, DK, FI). It encodes the standard statutory VAT rates:
//
// SE: 25 % (standard), 12 % (food, hotels), 6 % (books, transport)
// NO: 25 % (standard), 15 % (food), 12 %
// DK: 25 % (standard)
// FI: 25 % (standard; actual rate is 25.5 % as of 2026)
//
// All rates are raw percent (25 = 25 %). Fractional rates (FI 25.5) are
// truncated to the nearest integer per the raw-percent convention.
//
// NordicTaxProvider does not make network calls — all logic is local math.
// An external provider (Avalara, TaxJar) can be added by implementing
// TaxProvider and switching on TAX_PROVIDER.
type NordicTaxProvider struct {
defaultCountry string
}
// NewNordicTaxProvider returns a NordicTaxProvider. defaultCountry is used
// when DefaultTaxRate is called with an empty country code; it defaults to
// "SE" if empty.
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
if defaultCountry == "" {
defaultCountry = "SE"
}
return &NordicTaxProvider{defaultCountry: defaultCountry}
}
var _ TaxProvider = (*NordicTaxProvider)(nil)
func (p *NordicTaxProvider) Name() string { return "nordic" }
func (p *NordicTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// nordicStandardRates maps country code → default standard VAT rate (raw percent).
var nordicStandardRates = map[string]int{
"SE": 25,
"NO": 25,
"DK": 25,
"FI": 25,
}
func (p *NordicTaxProvider) DefaultTaxRate(country string) int {
if country == "" {
country = p.defaultCountry
}
if rate, ok := nordicStandardRates[country]; ok {
return rate
}
return nordicStandardRates[p.defaultCountry]
}
+113
View File
@@ -0,0 +1,113 @@
package cart
import (
"testing"
)
func TestNordicTaxProvider_Name(t *testing.T) {
p := NewNordicTaxProvider("SE")
if got := p.Name(); got != "nordic" {
t.Errorf("Name() = %q, want %q", got, "nordic")
}
}
func TestNordicTaxProvider_ComputeTax(t *testing.T) {
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{0, 25, 0, "zero total"},
{1250, 25, 250, "25 % VAT on 1250"},
{1000, 20, 166, "20 % VAT on 1000"},
{1200, 25, 240, "25 % VAT on 1200"},
{25000, 25, 5000, "25 % VAT on 25000"},
{100, 10, 9, "10 % VAT on 100"},
{100, 100, 50, "100 % VAT on 100"},
{100, 0, 0, "zero-rate"},
{100, -1, 0, "negative rate -> zero"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
got := p.ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
func TestNordicTaxProvider_DefaultTaxRate(t *testing.T) {
tests := []struct {
country string
want int
desc string
}{
{"SE", 25, "Sweden"},
{"NO", 25, "Norway"},
{"DK", 25, "Denmark"},
{"FI", 25, "Finland"},
{"", 25, "empty -> default SE"},
{"DE", 25, "unknown -> fallback SE"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
got := p.DefaultTaxRate(tt.country)
if got != tt.want {
t.Errorf("DefaultTaxRate(%q) [%s] = %d; want %d", tt.country, tt.desc, got, tt.want)
}
}
}
func TestStaticTaxProvider_ComputeTax(t *testing.T) {
p := NewStaticTaxProvider()
tests := []struct {
total int64
taxRate int
want int64
}{
{1250, 25, 250},
{25000, 25, 5000},
{1000, 20, 166},
{0, 25, 0},
}
for _, tt := range tests {
got := p.ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) = %d; want %d", tt.total, tt.taxRate, got, tt.want)
}
}
}
func TestStaticTaxProvider_DefaultTaxRate(t *testing.T) {
p := NewStaticTaxProvider()
if got := p.DefaultTaxRate("SE"); got != 0 {
t.Errorf("StaticTaxProvider.DefaultTaxRate(\"SE\") = %d; want 0", got)
}
}
func TestComputeTax(t *testing.T) {
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{1250, 25, 250, "canonical: 25 %"},
{25000, 25, 5000, "25k with 25 %% -> 5k tax"},
{0, 25, 0, "zero total"},
}
for _, tt := range tests {
got := ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
func TestTaxProviderImplementsInterface(t *testing.T) {
var p TaxProvider = NewNordicTaxProvider("SE")
_ = p
p2 := NewStaticTaxProvider()
_ = p2
}