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