Complete refactor to new grpc control plane and only http proxy for carts (#4)
Co-authored-by: matst80 <mats.tornberg@gmail.com> Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/4 Co-authored-by: Mats Törnberg <mats@tornberg.me> Co-committed-by: Mats Törnberg <mats@tornberg.me>
This commit was merged in pull request #4.
This commit is contained in:
61
cmd/cart/amqp-order-handler.go
Normal file
61
cmd/cart/amqp-order-handler.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type AmqpOrderHandler struct {
|
||||
Url string
|
||||
Connection *amqp.Connection
|
||||
Channel *amqp.Channel
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Connect() error {
|
||||
conn, err := amqp.Dial(h.Url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||
}
|
||||
h.Connection = conn
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
h.Channel = ch
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Close() error {
|
||||
if h.Channel != nil {
|
||||
h.Channel.Close()
|
||||
}
|
||||
if h.Connection != nil {
|
||||
return h.Connection.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.Channel.PublishWithContext(ctx,
|
||||
"orders", // exchange
|
||||
"new", // routing key
|
||||
false, // mandatory
|
||||
false, // immediate
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish a message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
268
cmd/cart/cart-grain.go
Normal file
268
cmd/cart/cart-grain.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||
)
|
||||
|
||||
// Legacy padded [16]byte CartId and its helper methods removed.
|
||||
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
|
||||
|
||||
type StockStatus int
|
||||
|
||||
type ItemMeta struct {
|
||||
Name string `json:"name"`
|
||||
Brand string `json:"brand,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Category2 string `json:"category2,omitempty"`
|
||||
Category3 string `json:"category3,omitempty"`
|
||||
Category4 string `json:"category4,omitempty"`
|
||||
Category5 string `json:"category5,omitempty"`
|
||||
SellerId string `json:"sellerId,omitempty"`
|
||||
SellerName string `json:"sellerName,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Outlet *string `json:"outlet,omitempty"`
|
||||
}
|
||||
|
||||
type CartItem struct {
|
||||
Id uint32 `json:"id"`
|
||||
ItemId uint32 `json:"itemId,omitempty"`
|
||||
ParentId uint32 `json:"parentId,omitempty"`
|
||||
Sku string `json:"sku"`
|
||||
Price Price `json:"price"`
|
||||
TotalPrice Price `json:"totalPrice"`
|
||||
OrgPrice *Price `json:"orgPrice,omitempty"`
|
||||
Stock StockStatus `json:"stock"`
|
||||
Quantity int `json:"qty"`
|
||||
Discount *Price `json:"discount,omitempty"`
|
||||
Disclaimer string `json:"disclaimer,omitempty"`
|
||||
ArticleType string `json:"type,omitempty"`
|
||||
StoreId *string `json:"storeId,omitempty"`
|
||||
Meta *ItemMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type CartDelivery struct {
|
||||
Id uint32 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Price Price `json:"price"`
|
||||
Items []uint32 `json:"items"`
|
||||
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||
}
|
||||
|
||||
type CartNotification struct {
|
||||
LinkedId int `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type CartGrain struct {
|
||||
mu sync.RWMutex
|
||||
lastItemId uint32
|
||||
lastDeliveryId uint32
|
||||
lastVoucherId uint32
|
||||
lastAccess time.Time
|
||||
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
||||
userId string
|
||||
Id CartId `json:"id"`
|
||||
Items []*CartItem `json:"items"`
|
||||
TotalPrice *Price `json:"totalPrice"`
|
||||
TotalDiscount *Price `json:"totalDiscount"`
|
||||
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
|
||||
Processing bool `json:"processing"`
|
||||
PaymentInProgress bool `json:"paymentInProgress"`
|
||||
OrderReference string `json:"orderReference,omitempty"`
|
||||
PaymentStatus string `json:"paymentStatus,omitempty"`
|
||||
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||
}
|
||||
|
||||
type Voucher struct {
|
||||
Code string `json:"code"`
|
||||
Rules []*messages.VoucherRule `json:"rules"`
|
||||
Id uint32 `json:"id"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||
// No rules -> applies to entire cart
|
||||
if len(v.Rules) == 0 {
|
||||
return cart.Items, true
|
||||
}
|
||||
|
||||
// Build evaluation context once
|
||||
ctx := voucher.EvalContext{
|
||||
Items: make([]voucher.Item, 0, len(cart.Items)),
|
||||
CartTotalInc: 0,
|
||||
}
|
||||
|
||||
if cart.TotalPrice != nil {
|
||||
ctx.CartTotalInc = cart.TotalPrice.IncVat
|
||||
}
|
||||
|
||||
for _, it := range cart.Items {
|
||||
category := ""
|
||||
if it.Meta != nil {
|
||||
category = it.Meta.Category
|
||||
}
|
||||
ctx.Items = append(ctx.Items, voucher.Item{
|
||||
Sku: it.Sku,
|
||||
Category: category,
|
||||
UnitPrice: it.Price.IncVat,
|
||||
})
|
||||
}
|
||||
|
||||
// All voucher rules must pass (logical AND)
|
||||
for _, rule := range v.Rules {
|
||||
expr := rule.GetCondition()
|
||||
if expr == "" {
|
||||
// Empty condition treated as pass (acts like a comment / placeholder)
|
||||
continue
|
||||
}
|
||||
rs, err := voucher.ParseRules(expr)
|
||||
if err != nil {
|
||||
// Fail closed on parse error
|
||||
return nil, false
|
||||
}
|
||||
if !rs.Applies(ctx) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
return cart.Items, true
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetId() uint64 {
|
||||
return uint64(c.Id)
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetLastChange() time.Time {
|
||||
return c.lastChange
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetLastAccess() time.Time {
|
||||
return c.lastAccess
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||
c.lastAccess = time.Now()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid type")
|
||||
}
|
||||
return int(data), nil
|
||||
}
|
||||
|
||||
// func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
|
||||
// cartItem, err := getItemData(sku, qty, country)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// cartItem.StoreId = storeId
|
||||
// return c.Apply(cartItem, false)
|
||||
// }
|
||||
|
||||
func (c *CartGrain) GetState() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func (c *CartGrain) ItemsWithDelivery() []uint32 {
|
||||
ret := make([]uint32, 0, len(c.Items))
|
||||
for _, item := range c.Items {
|
||||
for _, delivery := range c.Deliveries {
|
||||
for _, id := range delivery.Items {
|
||||
if item.Id == id {
|
||||
ret = append(ret, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *CartGrain) ItemsWithoutDelivery() []uint32 {
|
||||
ret := make([]uint32, 0, len(c.Items))
|
||||
hasDelivery := c.ItemsWithDelivery()
|
||||
for _, item := range c.Items {
|
||||
found := slices.Contains(hasDelivery, item.Id)
|
||||
|
||||
if !found {
|
||||
ret = append(ret, item.Id)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
for _, item := range c.Items {
|
||||
if item.Sku == sku {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// func (c *CartGrain) Apply(content proto.Message, isReplay bool) (*CartGrain, error) {
|
||||
|
||||
// updated, err := ApplyRegistered(c, content)
|
||||
// if err != nil {
|
||||
// if err == ErrMutationNotRegistered {
|
||||
// return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
|
||||
// }
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// // Sliding TTL: update lastChange only for non-replay successful mutations.
|
||||
// if updated != nil && !isReplay {
|
||||
// c.lastChange = time.Now()
|
||||
// c.lastAccess = time.Now()
|
||||
// go AppendCartEvent(c.Id, content)
|
||||
// }
|
||||
|
||||
// return updated, nil
|
||||
// }
|
||||
|
||||
func (c *CartGrain) UpdateTotals() {
|
||||
c.TotalPrice = NewPrice()
|
||||
c.TotalDiscount = NewPrice()
|
||||
|
||||
for _, item := range c.Items {
|
||||
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
|
||||
|
||||
item.TotalPrice = *rowTotal
|
||||
|
||||
c.TotalPrice.Add(*rowTotal)
|
||||
|
||||
if item.OrgPrice != nil {
|
||||
diff := NewPrice()
|
||||
diff.Add(*item.OrgPrice)
|
||||
diff.Subtract(item.Price)
|
||||
if diff.IncVat > 0 {
|
||||
c.TotalDiscount.Add(*diff)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
for _, delivery := range c.Deliveries {
|
||||
c.TotalPrice.Add(delivery.Price)
|
||||
}
|
||||
for _, voucher := range c.Vouchers {
|
||||
if _, ok := voucher.AppliesTo(c); ok {
|
||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||
|
||||
c.TotalDiscount.Add(*value)
|
||||
c.TotalPrice.Subtract(*value)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
cmd/cart/cart_grain_totals_test.go
Normal file
48
cmd/cart/cart_grain_totals_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helper to create a cart grain with items and deliveries
|
||||
func newTestCart() *CartGrain {
|
||||
return &CartGrain{Items: []*CartItem{}, Deliveries: []*CartDelivery{}, Vouchers: []*Voucher{}, Notifications: []CartNotification{}}
|
||||
}
|
||||
|
||||
func TestCartGrainUpdateTotalsBasic(t *testing.T) {
|
||||
c := newTestCart()
|
||||
// Item1 price 1250 (ex 1000 vat 250) org price higher -> discount 200 per unit
|
||||
item1Price := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
||||
item1Org := &Price{IncVat: 1500, VatRates: map[float32]int64{25: 300}}
|
||||
item2Price := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
|
||||
c.Items = []*CartItem{
|
||||
{Id: 1, Price: item1Price, OrgPrice: item1Org, Quantity: 2},
|
||||
{Id: 2, Price: item2Price, OrgPrice: &item2Price, Quantity: 1},
|
||||
}
|
||||
deliveryPrice := Price{IncVat: 4900, VatRates: map[float32]int64{25: 980}}
|
||||
c.Deliveries = []*CartDelivery{{Id: 1, Price: deliveryPrice, Items: []uint32{1, 2}}}
|
||||
|
||||
c.UpdateTotals()
|
||||
|
||||
// Expected totals: sum inc vat of items * qty plus delivery
|
||||
// item1 total inc = 1250*2 = 2500
|
||||
// item2 total inc = 2000*1 = 2000
|
||||
// delivery inc = 4900
|
||||
expectedInc := int64(2500 + 2000 + 4900)
|
||||
if c.TotalPrice.IncVat != expectedInc {
|
||||
t.Fatalf("TotalPrice IncVat expected %d got %d", expectedInc, c.TotalPrice.IncVat)
|
||||
}
|
||||
|
||||
// Discount: current implementation computes (OrgPrice - Price) ignoring quantity -> 1500-1250=250
|
||||
if c.TotalDiscount.IncVat != 250 {
|
||||
t.Fatalf("TotalDiscount expected 250 got %d", c.TotalDiscount.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCartGrainUpdateTotalsNoItems(t *testing.T) {
|
||||
c := newTestCart()
|
||||
c.UpdateTotals()
|
||||
if c.TotalPrice.IncVat != 0 || c.TotalDiscount.IncVat != 0 {
|
||||
t.Fatalf("expected zero totals got %+v", c)
|
||||
}
|
||||
}
|
||||
159
cmd/cart/cart_id.go
Normal file
159
cmd/cart/cart_id.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// cart_id.go
|
||||
//
|
||||
// Breaking change:
|
||||
// Unified cart identifier as a raw 64-bit unsigned integer (type CartId uint64).
|
||||
// External textual representation: base62 (0-9 A-Z a-z), shortest possible
|
||||
// encoding for 64 bits (max 11 characters, since 62^11 > 2^64).
|
||||
//
|
||||
// Rationale:
|
||||
// - Replaces legacy fixed [16]byte padded string and transitional CartID wrapper.
|
||||
// - Provides compact, URL/cookie-friendly identifiers.
|
||||
// - O(1) hashing and minimal memory footprint.
|
||||
// - 64 bits of crypto randomness => negligible collision probability at realistic scale.
|
||||
//
|
||||
// Public API:
|
||||
// type CartId uint64
|
||||
// func NewCartId() (CartId, error)
|
||||
// func MustNewCartId() CartId
|
||||
// func ParseCartId(string) (CartId, bool)
|
||||
// func MustParseCartId(string) CartId
|
||||
// (CartId).String() string
|
||||
// (CartId).MarshalJSON() / UnmarshalJSON()
|
||||
//
|
||||
// NOTE:
|
||||
// All legacy helpers (UpgradeLegacyCartId, Fallback hashing, Canonicalize variants,
|
||||
// CartIDToLegacy, LegacyToCartID) have been removed as part of the breaking change.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CartId uint64
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// Reverse lookup (0xFF marks invalid)
|
||||
var base62Rev [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range base62Rev {
|
||||
base62Rev[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(base62Alphabet); i++ {
|
||||
base62Rev[base62Alphabet[i]] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoding of the 64-bit id.
|
||||
func (id CartId) String() string {
|
||||
return encodeBase62(uint64(id))
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the cart id as a JSON string.
|
||||
func (id CartId) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||
func (id *CartId) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, ok := ParseCartId(s)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id: %q", s)
|
||||
}
|
||||
*id = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewCartId generates a new cryptographically random non-zero 64-bit id.
|
||||
func NewCartId() (CartId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("NewCartId: %w", err)
|
||||
}
|
||||
u := (uint64(b[0]) << 56) |
|
||||
(uint64(b[1]) << 48) |
|
||||
(uint64(b[2]) << 40) |
|
||||
(uint64(b[3]) << 32) |
|
||||
(uint64(b[4]) << 24) |
|
||||
(uint64(b[5]) << 16) |
|
||||
(uint64(b[6]) << 8) |
|
||||
uint64(b[7])
|
||||
if u == 0 {
|
||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
||||
return NewCartId()
|
||||
}
|
||||
return CartId(u), nil
|
||||
}
|
||||
|
||||
// MustNewCartId panics if generation fails.
|
||||
func MustNewCartId() CartId {
|
||||
id, err := NewCartId()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ParseCartId parses a base62 string into a CartId.
|
||||
// Returns (0,false) for invalid input.
|
||||
func ParseCartId(s string) (CartId, bool) {
|
||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
||||
// extensions; here we stay strict.
|
||||
if len(s) == 0 || len(s) > 11 {
|
||||
return 0, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return CartId(u), true
|
||||
}
|
||||
|
||||
// MustParseCartId panics on invalid base62 input.
|
||||
func MustParseCartId(s string) CartId {
|
||||
id, ok := ParseCartId(s)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("invalid cart id: %q", s))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
||||
func encodeBase62(u uint64) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [11]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = base62Alphabet[u%62]
|
||||
u /= 62
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts base62 text to uint64.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
185
cmd/cart/cart_id_test.go
Normal file
185
cmd/cart/cart_id_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewCartIdUniqueness generates many ids and checks for collisions.
|
||||
func TestNewCartIdUniqueness(t *testing.T) {
|
||||
const n = 20000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
id, err := NewCartId()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCartId error: %v", err)
|
||||
}
|
||||
s := id.String()
|
||||
if _, exists := seen[s]; exists {
|
||||
t.Fatalf("duplicate id encountered: %s", s)
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
if s == "" {
|
||||
t.Fatalf("empty string representation for id %d", id)
|
||||
}
|
||||
if len(s) > 11 {
|
||||
t.Fatalf("encoded id length exceeds 11 chars: %s (%d)", s, len(s))
|
||||
}
|
||||
if id == 0 {
|
||||
// We force regeneration on zero, extremely unlikely but test guards intent.
|
||||
t.Fatalf("zero id generated (should be regenerated)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCartIdRoundTrip ensures parse -> string -> parse is stable.
|
||||
func TestParseCartIdRoundTrip(t *testing.T) {
|
||||
id := MustNewCartId()
|
||||
txt := id.String()
|
||||
parsed, ok := ParseCartId(txt)
|
||||
if !ok {
|
||||
t.Fatalf("ParseCartId failed for valid text %q", txt)
|
||||
}
|
||||
if parsed != id {
|
||||
t.Fatalf("round trip mismatch: original=%d parsed=%d txt=%s", id, parsed, txt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCartIdInvalid covers invalid inputs.
|
||||
func TestParseCartIdInvalid(t *testing.T) {
|
||||
invalid := []string{
|
||||
"", // empty
|
||||
" ", // space
|
||||
"01234567890abc", // >11 chars
|
||||
"!!!!", // invalid chars
|
||||
"-underscore-", // invalid chars
|
||||
"abc_def", // underscore invalid for base62
|
||||
"0123456789ABCD", // 14 chars
|
||||
}
|
||||
for _, s := range invalid {
|
||||
if _, ok := ParseCartId(s); ok {
|
||||
t.Fatalf("expected parse failure for %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustParseCartIdPanics verifies panic behavior for invalid input.
|
||||
func TestMustParseCartIdPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("expected panic for invalid MustParseCartId input")
|
||||
}
|
||||
}()
|
||||
_ = MustParseCartId("not*base62")
|
||||
}
|
||||
|
||||
// TestJSONMarshalUnmarshalCartId verifies JSON round trip.
|
||||
func TestJSONMarshalUnmarshalCartId(t *testing.T) {
|
||||
id := MustNewCartId()
|
||||
data, err := json.Marshal(struct {
|
||||
Cart CartId `json:"cart"`
|
||||
}{Cart: id})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
var out struct {
|
||||
Cart CartId `json:"cart"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if out.Cart != id {
|
||||
t.Fatalf("JSON round trip mismatch: have %d got %d", id, out.Cart)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBase62LengthBound checks worst-case length (near max uint64).
|
||||
func TestBase62LengthBound(t *testing.T) {
|
||||
// Largest uint64
|
||||
const maxU64 = ^uint64(0)
|
||||
s := encodeBase62(maxU64)
|
||||
if len(s) > 11 {
|
||||
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
|
||||
}
|
||||
dec, ok := decodeBase62(s)
|
||||
if !ok || dec != maxU64 {
|
||||
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
|
||||
func TestZeroEncoding(t *testing.T) {
|
||||
if s := encodeBase62(0); s != "0" {
|
||||
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
|
||||
}
|
||||
v, ok := decodeBase62("0")
|
||||
if !ok || v != 0 {
|
||||
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
|
||||
}
|
||||
if _, ok := ParseCartId("0"); !ok {
|
||||
t.Fatalf("ParseCartId(\"0\") should succeed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSequentialParse ensures sequentially generated ids parse correctly.
|
||||
func TestSequentialParse(t *testing.T) {
|
||||
for i := 0; i < 1000; i++ {
|
||||
id := MustNewCartId()
|
||||
txt := id.String()
|
||||
parsed, ok := ParseCartId(txt)
|
||||
if !ok || parsed != id {
|
||||
t.Fatalf("sequential parse mismatch: idx=%d orig=%d parsed=%d txt=%s", i, id, parsed, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkNewCartId measures generation performance.
|
||||
func BenchmarkNewCartId(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := NewCartId(); err != nil {
|
||||
b.Fatalf("NewCartId error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeBase62 measures encoding performance.
|
||||
func BenchmarkEncodeBase62(b *testing.B) {
|
||||
// Precompute sample values
|
||||
samples := make([]uint64, 1024)
|
||||
for i := range samples {
|
||||
// Spread bits without crypto randomness overhead
|
||||
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sink string
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = encodeBase62(samples[i%len(samples)])
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
|
||||
// BenchmarkDecodeBase62 measures decoding performance.
|
||||
func BenchmarkDecodeBase62(b *testing.B) {
|
||||
encoded := make([]string, 1024)
|
||||
for i := range encoded {
|
||||
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sum uint64
|
||||
for i := 0; i < b.N; i++ {
|
||||
v, ok := decodeBase62(encoded[i%len(encoded)])
|
||||
if !ok {
|
||||
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
|
||||
}
|
||||
sum ^= v
|
||||
}
|
||||
_ = sum
|
||||
}
|
||||
|
||||
// ExampleCartIdString documents usage of CartId string form.
|
||||
func ExampleCartId_string() {
|
||||
id := MustNewCartId()
|
||||
fmt.Println(len(id.String()) <= 11) // outputs true
|
||||
// Output: true
|
||||
}
|
||||
119
cmd/cart/checkout_builder.go
Normal file
119
cmd/cart/checkout_builder.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CheckoutMeta carries the external / URL metadata required to build a
|
||||
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
||||
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
||||
type CheckoutMeta struct {
|
||||
Terms string
|
||||
Checkout string
|
||||
Confirmation string
|
||||
Validation string
|
||||
Push string
|
||||
Country string
|
||||
Currency string // optional override (defaults to "SEK" if empty)
|
||||
Locale string // optional override (defaults to "sv-se" if empty)
|
||||
}
|
||||
|
||||
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
||||
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
|
||||
// (to send to Klarna) alongside the structured CheckoutOrder object.
|
||||
//
|
||||
// This function is PURE: it does not perform any network I/O or mutate the
|
||||
// grain. The caller is responsible for:
|
||||
//
|
||||
// 1. Choosing whether to create or update the Klarna order.
|
||||
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
|
||||
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
|
||||
// resulting Klarna order id + status.
|
||||
//
|
||||
// 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 *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
if grain == nil {
|
||||
return nil, nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if meta == nil {
|
||||
return nil, nil, fmt.Errorf("nil checkout meta")
|
||||
}
|
||||
|
||||
currency := meta.Currency
|
||||
if currency == "" {
|
||||
currency = "SEK"
|
||||
}
|
||||
locale := meta.Locale
|
||||
if locale == "" {
|
||||
locale = "sv-se"
|
||||
}
|
||||
country := meta.Country
|
||||
if country == "" {
|
||||
country = "SE" // sensible default; adjust if multi-country support changes
|
||||
}
|
||||
|
||||
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
|
||||
|
||||
// Item lines
|
||||
for _, it := range grain.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "physical",
|
||||
Reference: it.Sku,
|
||||
Name: it.Meta.Name,
|
||||
Quantity: it.Quantity,
|
||||
UnitPrice: int(it.Price.IncVat),
|
||||
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(it.TotalPrice.IncVat),
|
||||
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
|
||||
})
|
||||
}
|
||||
|
||||
// Delivery lines
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "shipping_fee",
|
||||
Reference: d.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: int(d.Price.IncVat),
|
||||
TaxRate: 2500,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(d.Price.IncVat),
|
||||
TotalTaxAmount: int(d.Price.TotalVat()),
|
||||
})
|
||||
}
|
||||
|
||||
order := &CheckoutOrder{
|
||||
PurchaseCountry: country,
|
||||
PurchaseCurrency: currency,
|
||||
Locale: locale,
|
||||
OrderAmount: int(grain.TotalPrice.IncVat),
|
||||
OrderTaxAmount: int(grain.TotalPrice.TotalVat()),
|
||||
OrderLines: lines,
|
||||
MerchantReference1: grain.Id.String(),
|
||||
MerchantURLS: &CheckoutMerchantURLS{
|
||||
Terms: meta.Terms,
|
||||
Checkout: meta.Checkout,
|
||||
Confirmation: meta.Confirmation,
|
||||
Validation: meta.Validation,
|
||||
Push: meta.Push,
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
|
||||
}
|
||||
|
||||
return payload, order, nil
|
||||
}
|
||||
128
cmd/cart/klarna-client.go
Normal file
128
cmd/cart/klarna-client.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type KlarnaClient struct {
|
||||
Url string
|
||||
UserName string
|
||||
Password string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewKlarnaClient(url, userName, password string) *KlarnaClient {
|
||||
return &KlarnaClient{
|
||||
Url: url,
|
||||
UserName: userName,
|
||||
Password: password,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
|
||||
)
|
||||
|
||||
func (k *KlarnaClient) GetOrder(orderId string) (*CheckoutOrder, error) {
|
||||
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
res, err := k.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
return k.getOrderResponse(res)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, error) {
|
||||
var err error
|
||||
var klarnaOrderResponse CheckoutOrder
|
||||
if res.StatusCode >= 200 && res.StatusCode <= 299 {
|
||||
err = json.NewDecoder(res.Body).Decode(&klarnaOrderResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &klarnaOrderResponse, nil
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err == nil {
|
||||
log.Println(string(body))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s", res.Status)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
||||
//bytes.NewReader(reply.Payload)
|
||||
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
return k.getOrderResponse(res)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
||||
//bytes.NewReader(reply.Payload)
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
return k.getOrderResponse(res)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) AbortOrder(orderId string) error {
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
_, err = http.DefaultClient.Do(req)
|
||||
return err
|
||||
}
|
||||
|
||||
// ordermanagement/v1/orders/{order_id}/acknowledge
|
||||
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := uuid.New()
|
||||
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
req.Header.Add("Klarna-Idempotency-Key", id.String())
|
||||
|
||||
_, err = http.DefaultClient.Do(req)
|
||||
return err
|
||||
}
|
||||
169
cmd/cart/klarna-types.go
Normal file
169
cmd/cart/klarna-types.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
type (
|
||||
LineType string
|
||||
|
||||
// CheckoutOrder type is the request structure to create a new order from the Checkout API
|
||||
CheckoutOrder struct {
|
||||
ID string `json:"order_id,omitempty"`
|
||||
PurchaseCountry string `json:"purchase_country"`
|
||||
PurchaseCurrency string `json:"purchase_currency"`
|
||||
Locale string `json:"locale"`
|
||||
Status string `json:"status,omitempty"`
|
||||
BillingAddress *Address `json:"billing_address,omitempty"`
|
||||
ShippingAddress *Address `json:"shipping_address,omitempty"`
|
||||
OrderAmount int `json:"order_amount"`
|
||||
OrderTaxAmount int `json:"order_tax_amount"`
|
||||
OrderLines []*Line `json:"order_lines"`
|
||||
Customer *CheckoutCustomer `json:"customer,omitempty"`
|
||||
MerchantURLS *CheckoutMerchantURLS `json:"merchant_urls"`
|
||||
HTMLSnippet string `json:"html_snippet,omitempty"`
|
||||
MerchantReference1 string `json:"merchant_reference1,omitempty"`
|
||||
MerchantReference2 string `json:"merchant_reference2,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
LastModifiedAt string `json:"last_modified_at,omitempty"`
|
||||
Options *CheckoutOptions `json:"options,omitempty"`
|
||||
Attachment *Attachment `json:"attachment,omitempty"`
|
||||
ExternalPaymentMethods []*PaymentProvider `json:"external_payment_methods,omitempty"`
|
||||
ExternalCheckouts []*PaymentProvider `json:"external_checkouts,omitempty"`
|
||||
ShippingCountries []string `json:"shipping_countries,omitempty"`
|
||||
ShippingOptions []*ShippingOption `json:"shipping_options,omitempty"`
|
||||
MerchantData string `json:"merchant_data,omitempty"`
|
||||
GUI *GUI `json:"gui,omitempty"`
|
||||
MerchantRequested *AdditionalCheckBox `json:"merchant_requested,omitempty"`
|
||||
SelectedShippingOption *ShippingOption `json:"selected_shipping_option,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessages []string `json:"error_messages,omitempty"`
|
||||
}
|
||||
|
||||
// GUI type wraps the GUI options
|
||||
GUI struct {
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// ShippingOption type is part of the CheckoutOrder structure, represent the shipping options field
|
||||
ShippingOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Promo string `json:"promo,omitempty"`
|
||||
Price int `json:"price"`
|
||||
TaxAmount int `json:"tax_amount"`
|
||||
TaxRate int `json:"tax_rate"`
|
||||
Preselected bool `json:"preselected,omitempty"`
|
||||
ShippingMethod string `json:"shipping_method,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentProvider type is part of the CheckoutOrder structure, represent the ExternalPaymentMethods and
|
||||
// ExternalCheckouts field
|
||||
PaymentProvider struct {
|
||||
Name string `json:"name"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
Fee int `json:"fee,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Countries []string `json:"countries,omitempty"`
|
||||
}
|
||||
|
||||
Attachment struct {
|
||||
ContentType string `json:"content_type"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
CheckoutOptions struct {
|
||||
AcquiringChannel string `json:"acquiring_channel,omitempty"`
|
||||
AllowSeparateShippingAddress bool `json:"allow_separate_shipping_address,omitempty"`
|
||||
ColorButton string `json:"color_button,omitempty"`
|
||||
ColorButtonText string `json:"color_button_text,omitempty"`
|
||||
ColorCheckbox string `json:"color_checkbox,omitempty"`
|
||||
ColorCheckboxCheckmark string `json:"color_checkbox_checkmark,omitempty"`
|
||||
ColorHeader string `json:"color_header,omitempty"`
|
||||
ColorLink string `json:"color_link,omitempty"`
|
||||
DateOfBirthMandatory bool `json:"date_of_birth_mandatory,omitempty"`
|
||||
ShippingDetails string `json:"shipping_details,omitempty"`
|
||||
TitleMandatory bool `json:"title_mandatory,omitempty"`
|
||||
AdditionalCheckbox *AdditionalCheckBox `json:"additional_checkbox"`
|
||||
RadiusBorder string `json:"radius_border,omitempty"`
|
||||
ShowSubtotalDetail bool `json:"show_subtotal_detail,omitempty"`
|
||||
RequireValidateCallbackSuccess bool `json:"require_validate_callback_success,omitempty"`
|
||||
AllowGlobalBillingCountries bool `json:"allow_global_billing_countries,omitempty"`
|
||||
}
|
||||
|
||||
AdditionalCheckBox struct {
|
||||
Text string `json:"text"`
|
||||
Checked bool `json:"checked"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
CheckoutMerchantURLS struct {
|
||||
// URL of merchant terms and conditions. Should be different than checkout, confirmation and push URLs.
|
||||
// (max 2000 characters)
|
||||
Terms string `json:"terms"`
|
||||
|
||||
// URL of merchant checkout page. Should be different than terms, confirmation and push URLs.
|
||||
// (max 2000 characters)
|
||||
Checkout string `json:"checkout"`
|
||||
|
||||
// URL of merchant confirmation page. Should be different than checkout and confirmation URLs.
|
||||
// (max 2000 characters)
|
||||
Confirmation string `json:"confirmation"`
|
||||
|
||||
// URL that will be requested when an order is completed. Should be different than checkout and
|
||||
// confirmation URLs. (max 2000 characters)
|
||||
Push string `json:"push"`
|
||||
// URL that will be requested for final merchant validation. (must be https, max 2000 characters)
|
||||
Validation string `json:"validation,omitempty"`
|
||||
|
||||
// URL for shipping option update. (must be https, max 2000 characters)
|
||||
ShippingOptionUpdate string `json:"shipping_option_update,omitempty"`
|
||||
|
||||
// URL for shipping, tax and purchase currency updates. Will be called on address changes.
|
||||
// (must be https, max 2000 characters)
|
||||
AddressUpdate string `json:"address_update,omitempty"`
|
||||
|
||||
// URL for notifications on pending orders. (max 2000 characters)
|
||||
Notification string `json:"notification,omitempty"`
|
||||
|
||||
// URL for shipping, tax and purchase currency updates. Will be called on purchase country changes.
|
||||
// (must be https, max 2000 characters)
|
||||
CountryChange string `json:"country_change,omitempty"`
|
||||
}
|
||||
|
||||
CheckoutCustomer struct {
|
||||
// DateOfBirth in string representation 2006-01-02
|
||||
DateOfBirth string `json:"date_of_birth"`
|
||||
}
|
||||
|
||||
// Address type define the address object (json serializable) being used for the API to represent billing &
|
||||
// shipping addresses
|
||||
Address struct {
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
StreetAddress string `json:"street_address,omitempty"`
|
||||
StreetAddress2 string `json:"street_address2,omitempty"`
|
||||
PostalCode string `json:"postal_code,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
}
|
||||
|
||||
Line struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Reference string `json:"reference,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
QuantityUnit string `json:"quantity_unit,omitempty"`
|
||||
UnitPrice int `json:"unit_price"`
|
||||
TaxRate int `json:"tax_rate"`
|
||||
TotalAmount int `json:"total_amount"`
|
||||
TotalDiscountAmount int `json:"total_discount_amount,omitempty"`
|
||||
TotalTaxAmount int `json:"total_tax_amount"`
|
||||
MerchantData string `json:"merchant_data,omitempty"`
|
||||
ProductURL string `json:"product_url,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
}
|
||||
)
|
||||
461
cmd/cart/main.go
Normal file
461
cmd/cart/main.go
Normal file
@@ -0,0 +1,461 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/discovery"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/proxy"
|
||||
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
var (
|
||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_spawned_total",
|
||||
Help: "The total number of spawned grains",
|
||||
})
|
||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_mutations_total",
|
||||
Help: "The total number of mutations",
|
||||
})
|
||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_lookups_total",
|
||||
Help: "The total number of lookups",
|
||||
})
|
||||
)
|
||||
|
||||
func init() {
|
||||
os.Mkdir("data", 0755)
|
||||
}
|
||||
|
||||
type App struct {
|
||||
pool *actor.SimpleGrainPool[CartGrain]
|
||||
}
|
||||
|
||||
var podIp = os.Getenv("POD_IP")
|
||||
var name = os.Getenv("POD_NAME")
|
||||
var amqpUrl = os.Getenv("AMQP_URL")
|
||||
|
||||
var tpl = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>s10r testing - checkout</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
%s
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func getCountryFromHost(host string) string {
|
||||
if strings.Contains(strings.ToLower(host), "-no") {
|
||||
return "no"
|
||||
}
|
||||
if strings.Contains(strings.ToLower(host), "-se") {
|
||||
return "se"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetDiscovery() discovery.Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
return discovery.NewK8sDiscovery(client)
|
||||
}
|
||||
|
||||
type MutationContext struct {
|
||||
VoucherService voucher.Service
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
controlPlaneConfig := actor.DefaultServerConfig()
|
||||
|
||||
reg := actor.NewMutationRegistry()
|
||||
reg.RegisterMutations(
|
||||
actor.NewMutation(AddItem, func() *messages.AddItem {
|
||||
return &messages.AddItem{}
|
||||
}),
|
||||
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
|
||||
return &messages.ChangeQuantity{}
|
||||
}),
|
||||
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
|
||||
return &messages.RemoveItem{}
|
||||
}),
|
||||
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
|
||||
return &messages.InitializeCheckout{}
|
||||
}),
|
||||
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
|
||||
return &messages.OrderCreated{}
|
||||
}),
|
||||
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
|
||||
return &messages.RemoveDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
|
||||
return &messages.SetDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
|
||||
return &messages.SetPickupPoint{}
|
||||
}),
|
||||
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
|
||||
return &messages.ClearCartRequest{}
|
||||
}),
|
||||
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
|
||||
return &messages.AddVoucher{}
|
||||
}),
|
||||
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
|
||||
return &messages.RemoveVoucher{}
|
||||
}),
|
||||
)
|
||||
diskStorage := actor.NewDiskStorage[CartGrain]("data", reg)
|
||||
poolConfig := actor.GrainPoolConfig[CartGrain]{
|
||||
MutationRegistry: reg,
|
||||
Storage: diskStorage,
|
||||
Spawn: func(id uint64) (actor.Grain[CartGrain], error) {
|
||||
grainSpawns.Inc()
|
||||
ret := &CartGrain{
|
||||
lastItemId: 0,
|
||||
lastDeliveryId: 0,
|
||||
Deliveries: []*CartDelivery{},
|
||||
Id: CartId(id),
|
||||
Items: []*CartItem{},
|
||||
TotalPrice: NewPrice(),
|
||||
}
|
||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||
ret.lastChange = time.Now()
|
||||
ret.lastAccess = time.Now()
|
||||
|
||||
err := diskStorage.LoadEvents(id, ret)
|
||||
|
||||
return ret, err
|
||||
},
|
||||
SpawnHost: func(host string) (actor.Host, error) {
|
||||
return proxy.NewRemoteHost(host)
|
||||
},
|
||||
TTL: 15 * time.Minute,
|
||||
PoolSize: 2 * 65535,
|
||||
Hostname: podIp,
|
||||
}
|
||||
|
||||
pool, err := actor.NewSimpleGrainPool(poolConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating cart pool: %v\n", err)
|
||||
}
|
||||
app := &App{
|
||||
pool: pool,
|
||||
}
|
||||
|
||||
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
go diskStorage.SaveLoop(10 * time.Second)
|
||||
|
||||
go func(hw discovery.Discovery) {
|
||||
if hw == nil {
|
||||
log.Print("No discovery service available")
|
||||
return
|
||||
}
|
||||
ch, err := hw.Watch()
|
||||
if err != nil {
|
||||
log.Printf("Discovery error: %v", err)
|
||||
return
|
||||
}
|
||||
for evt := range ch {
|
||||
if evt.Host == "" {
|
||||
continue
|
||||
}
|
||||
switch evt.Type {
|
||||
case watch.Deleted:
|
||||
if pool.IsKnown(evt.Host) {
|
||||
pool.RemoveHost(evt.Host)
|
||||
}
|
||||
default:
|
||||
if !pool.IsKnown(evt.Host) {
|
||||
log.Printf("Discovered host %s", evt.Host)
|
||||
pool.AddRemote(evt.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(GetDiscovery())
|
||||
|
||||
orderHandler := &AmqpOrderHandler{
|
||||
Url: amqpUrl,
|
||||
}
|
||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||
// only for local
|
||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||
pool.AddRemote(r.PathValue("host"))
|
||||
})
|
||||
// mux.HandleFunc("GET /save", app.HandleSave)
|
||||
//mux.HandleFunc("/", app.RewritePath)
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
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()
|
||||
if grainCount >= capacity {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("grain pool at capacity"))
|
||||
return
|
||||
}
|
||||
if !pool.IsHealthy() {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("control plane not healthy"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
order := &CheckoutOrder{}
|
||||
|
||||
if orderId == "" {
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
if cookie.Value == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("no cart id to checkout is empty"))
|
||||
return
|
||||
}
|
||||
parsed, ok := ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid cart id format"))
|
||||
return
|
||||
}
|
||||
cartId := parsed
|
||||
syncedServer.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
return nil
|
||||
})(cartId, w, r)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
|
||||
} else {
|
||||
order, err = klarnaClient.GetOrder(orderId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
}
|
||||
|
||||
})
|
||||
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
orderId := r.PathValue("order_id")
|
||||
order, err := klarnaClient.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if order.Status == "checkout_complete" {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
})
|
||||
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
order := &CheckoutOrder{}
|
||||
err := json.NewDecoder(r.Body).Decode(order)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
log.Printf("Klarna order validation: %s", order.ID)
|
||||
//err = confirmOrder(order, orderHandler)
|
||||
//if err != nil {
|
||||
// log.Printf("Error validating order: %v\n", err)
|
||||
// w.WriteHeader(http.StatusInternalServerError)
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//err = triggerOrderCompleted(err, syncedServer, order)
|
||||
//if err != nil {
|
||||
// log.Printf("Error processing cart message: %v\n", err)
|
||||
// w.WriteHeader(http.StatusInternalServerError)
|
||||
// return
|
||||
//}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
log.Printf("Order confirmation push: %s", orderId)
|
||||
|
||||
order, err := klarnaClient.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = confirmOrder(order, orderHandler)
|
||||
if err != nil {
|
||||
log.Printf("Error confirming order: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = triggerOrderCompleted(syncedServer, order)
|
||||
if err != nil {
|
||||
log.Printf("Error processing cart message: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = klarnaClient.AcknowledgeOrder(orderId)
|
||||
if err != nil {
|
||||
log.Printf("Error acknowledging order: %v\n", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1.0.0"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
done := make(chan bool, 1)
|
||||
signal.Notify(sigs, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
sig := <-sigs
|
||||
fmt.Println("Shutting down due to signal:", sig)
|
||||
diskStorage.Close()
|
||||
pool.Close()
|
||||
|
||||
done <- true
|
||||
}()
|
||||
|
||||
log.Print("Server started at port 8080")
|
||||
go http.ListenAndServe(":8080", mux)
|
||||
<-done
|
||||
|
||||
}
|
||||
|
||||
func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||
mutation := &messages.OrderCreated{
|
||||
OrderId: order.ID,
|
||||
Status: order.Status,
|
||||
}
|
||||
cid, ok := ParseCartId(order.MerchantReference1)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
||||
}
|
||||
_, applyErr := syncedServer.Apply(uint64(cid), mutation)
|
||||
|
||||
return applyErr
|
||||
}
|
||||
|
||||
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||
orderToSend, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = orderHandler.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer orderHandler.Close()
|
||||
err = orderHandler.OrderCompleted(orderToSend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
87
cmd/cart/mutation_add_item.go
Normal file
87
cmd/cart/mutation_add_item.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_add_item.go
|
||||
//
|
||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||
//
|
||||
// Behavior:
|
||||
// * Validates quantity > 0
|
||||
// * If an item with same SKU exists -> increases quantity
|
||||
// * Else creates a new CartItem with computed tax amounts
|
||||
// * Totals recalculated automatically via WithTotals()
|
||||
//
|
||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||
// must keep this handler in sync.
|
||||
|
||||
func AddItem(g *CartGrain, m *messages.AddItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddItem: nil payload")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Fast path: merge with existing item having same SKU
|
||||
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||
existing.Quantity += int(m.Quantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.lastItemId++
|
||||
taxRate := float32(25.0)
|
||||
if m.Tax > 0 {
|
||||
taxRate = float32(int(m.Tax) / 100)
|
||||
}
|
||||
|
||||
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
|
||||
|
||||
g.Items = append(g.Items, &CartItem{
|
||||
Id: g.lastItemId,
|
||||
ItemId: uint32(m.ItemId),
|
||||
Quantity: int(m.Quantity),
|
||||
Sku: m.Sku,
|
||||
Meta: &ItemMeta{
|
||||
Name: m.Name,
|
||||
Image: m.Image,
|
||||
Brand: m.Brand,
|
||||
Category: m.Category,
|
||||
Category2: m.Category2,
|
||||
Category3: m.Category3,
|
||||
Category4: m.Category4,
|
||||
Category5: m.Category5,
|
||||
Outlet: m.Outlet,
|
||||
SellerId: m.SellerId,
|
||||
SellerName: m.SellerName,
|
||||
},
|
||||
|
||||
Price: *pricePerItem,
|
||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
||||
|
||||
Stock: StockStatus(m.Stock),
|
||||
Disclaimer: m.Disclaimer,
|
||||
|
||||
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
|
||||
ArticleType: m.ArticleType,
|
||||
|
||||
StoreId: m.StoreId,
|
||||
})
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
|
||||
if orgPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
return NewPriceFromIncVat(orgPrice, taxRate)
|
||||
}
|
||||
64
cmd/cart/mutation_add_voucher.go
Normal file
64
cmd/cart/mutation_add_voucher.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
|
||||
if m == nil {
|
||||
return &actor.MutationError{
|
||||
Message: "RemoveVoucher: nil payload",
|
||||
Code: 1003,
|
||||
StatusCode: 400,
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
|
||||
return v.Id == m.Id
|
||||
}) {
|
||||
return &actor.MutationError{
|
||||
Message: "voucher not applied",
|
||||
Code: 1004,
|
||||
StatusCode: 400,
|
||||
}
|
||||
}
|
||||
|
||||
g.Vouchers = slices.DeleteFunc(g.Vouchers, func(v *Voucher) bool {
|
||||
return v.Id == m.Id
|
||||
})
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
|
||||
if m == nil {
|
||||
return &actor.MutationError{
|
||||
Message: "AddVoucher: nil payload",
|
||||
Code: 1001,
|
||||
StatusCode: 400,
|
||||
}
|
||||
}
|
||||
|
||||
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
|
||||
return v.Code == m.Code
|
||||
}) {
|
||||
return &actor.MutationError{
|
||||
Message: "voucher already applied",
|
||||
Code: 1002,
|
||||
StatusCode: 400,
|
||||
}
|
||||
}
|
||||
|
||||
g.lastVoucherId++
|
||||
g.Vouchers = append(g.Vouchers, &Voucher{
|
||||
Id: g.lastVoucherId,
|
||||
Code: m.Code,
|
||||
Rules: m.VoucherRules,
|
||||
Value: m.Value,
|
||||
})
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
54
cmd/cart/mutation_change_quantity.go
Normal file
54
cmd/cart/mutation_change_quantity.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_change_quantity.go
|
||||
//
|
||||
// Registers the ChangeQuantity mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||
// - If requested quantity <= 0 the line is removed.
|
||||
// - Otherwise the line's Quantity field is updated.
|
||||
// - Totals are recalculated (WithTotals).
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns an error if the item Id is not found.
|
||||
// - Returns an error if payload is nil (defensive).
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
|
||||
// the grain's implicit expectation that higher layers control access.
|
||||
// (If strict locking is required around every mutation, wrap logic in
|
||||
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||
|
||||
func ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||
}
|
||||
|
||||
foundIndex := -1
|
||||
for i, it := range g.Items {
|
||||
if it.Id == uint32(m.Id) {
|
||||
foundIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundIndex == -1 {
|
||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
if m.Quantity <= 0 {
|
||||
// Remove the item
|
||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
g.Items[foundIndex].Quantity = int(m.Quantity)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
44
cmd/cart/mutation_initialize_checkout.go
Normal file
44
cmd/cart/mutation_initialize_checkout.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_initialize_checkout.go
|
||||
//
|
||||
// Registers the InitializeCheckout mutation.
|
||||
// This mutation is invoked AFTER an external Klarna checkout session
|
||||
// has been successfully created or updated. It persists the Klarna
|
||||
// order reference / status and marks the cart as having a payment in progress.
|
||||
//
|
||||
// Behavior:
|
||||
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
|
||||
// - Sets PaymentStatus to the current Klarna status.
|
||||
// - Sets / updates PaymentInProgress flag.
|
||||
// - Does NOT alter pricing or line items (so no totals recalculation).
|
||||
//
|
||||
// Validation:
|
||||
// - Returns an error if payload is nil.
|
||||
// - Returns an error if orderId is empty (integrity guard).
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on upstream mutation serialization for a single grain. If
|
||||
// parallel checkout attempts are possible, add higher-level guards
|
||||
// (e.g. reject if PaymentInProgress already true unless reusing
|
||||
// the same OrderReference).
|
||||
|
||||
func InitializeCheckout(g *CartGrain, m *messages.InitializeCheckout) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("InitializeCheckout: nil payload")
|
||||
}
|
||||
if m.OrderId == "" {
|
||||
return fmt.Errorf("InitializeCheckout: missing orderId")
|
||||
}
|
||||
|
||||
g.OrderReference = m.OrderId
|
||||
g.PaymentStatus = m.Status
|
||||
g.PaymentInProgress = m.PaymentInProgress
|
||||
return nil
|
||||
}
|
||||
48
cmd/cart/mutation_order_created.go
Normal file
48
cmd/cart/mutation_order_created.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_order_created.go
|
||||
//
|
||||
// Registers the OrderCreated mutation.
|
||||
//
|
||||
// This mutation represents the completion (or state transition) of an order
|
||||
// initiated earlier via InitializeCheckout / external Klarna processing.
|
||||
// It finalizes (or updates) the cart's order metadata.
|
||||
//
|
||||
// Behavior:
|
||||
// - Validates payload non-nil and OrderId not empty.
|
||||
// - Sets (or overwrites) OrderReference with the provided OrderId.
|
||||
// - Sets PaymentStatus from payload.Status.
|
||||
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
|
||||
// - Does NOT adjust monetary totals (no WithTotals()).
|
||||
//
|
||||
// Notes / Future Extensions:
|
||||
// - If multiple order completion events can arrive (e.g., retries / webhook
|
||||
// replays), this handler is idempotent: it simply overwrites fields.
|
||||
// - If you need to guard against conflicting order IDs, add a check:
|
||||
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
|
||||
// - Add audit logging or metrics here if required.
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on the higher-level guarantee that Apply() calls are serialized
|
||||
// per grain. If out-of-order events are possible, embed versioning or
|
||||
// timestamps in the mutation and compare before applying changes.
|
||||
|
||||
func OrderCreated(g *CartGrain, m *messages.OrderCreated) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("OrderCreated: nil payload")
|
||||
}
|
||||
if m.OrderId == "" {
|
||||
return fmt.Errorf("OrderCreated: missing orderId")
|
||||
}
|
||||
|
||||
g.OrderReference = m.OrderId
|
||||
g.PaymentStatus = m.Status
|
||||
g.PaymentInProgress = false
|
||||
return nil
|
||||
}
|
||||
49
cmd/cart/mutation_remove_delivery.go
Normal file
49
cmd/cart/mutation_remove_delivery.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_remove_delivery.go
|
||||
//
|
||||
// Registers the RemoveDelivery mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the delivery entry whose Id == payload.Id.
|
||||
// - If not found, returns an error.
|
||||
// - Cart totals are recalculated (WithTotals) after removal.
|
||||
// - Items previously associated with that delivery simply become "without delivery";
|
||||
// subsequent delivery mutations can reassign them.
|
||||
//
|
||||
// Differences vs legacy:
|
||||
// - Legacy logic decremented TotalPrice explicitly before recalculating.
|
||||
// Here we rely solely on UpdateTotals() to recompute from remaining
|
||||
// deliveries and items (simpler / single source of truth).
|
||||
//
|
||||
// Future considerations:
|
||||
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
|
||||
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
|
||||
|
||||
func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("RemoveDelivery: nil payload")
|
||||
}
|
||||
targetID := uint32(m.Id)
|
||||
index := -1
|
||||
for i, d := range g.Deliveries {
|
||||
if d.Id == targetID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("RemoveDelivery: delivery id %d not found", m.Id)
|
||||
}
|
||||
|
||||
// Remove delivery (order not preserved beyond necessity)
|
||||
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
45
cmd/cart/mutation_remove_item.go
Normal file
45
cmd/cart/mutation_remove_item.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_remove_item.go
|
||||
//
|
||||
// Registers the RemoveItem mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the cart line whose local cart line Id == payload.Id
|
||||
// - If no such line exists returns an error
|
||||
// - Recalculates cart totals (WithTotals)
|
||||
//
|
||||
// Notes:
|
||||
// - This removes only the line item; any deliveries referencing the removed
|
||||
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||
// - If multiple lines somehow shared the same Id (should not happen), only
|
||||
// the first match would be removed—data integrity relies on unique line Ids.
|
||||
|
||||
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("RemoveItem: nil payload")
|
||||
}
|
||||
targetID := uint32(m.Id)
|
||||
|
||||
index := -1
|
||||
for i, it := range g.Items {
|
||||
if it.Id == targetID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
96
cmd/cart/mutation_set_delivery.go
Normal file
96
cmd/cart/mutation_set_delivery.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_set_delivery.go
|
||||
//
|
||||
// Registers the SetDelivery mutation.
|
||||
//
|
||||
// Semantics (mirrors legacy switch logic):
|
||||
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||
// - Each referenced cart line must exist.
|
||||
// - None of the referenced items may already belong to a delivery.
|
||||
// - Only those items are associated with the new delivery.
|
||||
// - If payload.Items is empty:
|
||||
// - All items currently without any delivery are associated with the new delivery.
|
||||
// - A new delivery line is created with:
|
||||
// - Auto-incremented delivery ID (cart-local)
|
||||
// - Provider from payload
|
||||
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||
// - Optional PickupPoint copied from payload
|
||||
// - Cart totals are recalculated (WithTotals)
|
||||
//
|
||||
// Error cases:
|
||||
// - Referenced item does not exist
|
||||
// - Referenced item already has a delivery
|
||||
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||
// Item scans are read-only and performed outside the lock for simplicity;
|
||||
// if stricter guarantees are needed, widen the lock section.
|
||||
//
|
||||
// Future extension points:
|
||||
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||
// - Validation of provider codes
|
||||
// - Multi-currency delivery pricing
|
||||
|
||||
func SetDelivery(g *CartGrain, m *messages.SetDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetDelivery: nil payload")
|
||||
}
|
||||
if m.Provider == "" {
|
||||
return fmt.Errorf("SetDelivery: provider is empty")
|
||||
}
|
||||
|
||||
withDelivery := g.ItemsWithDelivery()
|
||||
targetItems := make([]uint32, 0)
|
||||
|
||||
if len(m.Items) == 0 {
|
||||
// Use every item currently without a delivery
|
||||
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
||||
} else {
|
||||
// Validate explicit list
|
||||
for _, id64 := range m.Items {
|
||||
id := uint32(id64)
|
||||
found := false
|
||||
for _, it := range g.Items {
|
||||
if it.Id == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
||||
}
|
||||
if slices.Contains(withDelivery, id) {
|
||||
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
||||
}
|
||||
targetItems = append(targetItems, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(targetItems) == 0 {
|
||||
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
||||
}
|
||||
|
||||
// Append new delivery
|
||||
g.mu.Lock()
|
||||
g.lastDeliveryId++
|
||||
newId := g.lastDeliveryId
|
||||
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||
Id: newId,
|
||||
Provider: m.Provider,
|
||||
PickupPoint: m.PickupPoint,
|
||||
Price: *NewPriceFromIncVat(4900, 25.0),
|
||||
Items: targetItems,
|
||||
})
|
||||
g.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
62
cmd/cart/mutation_set_pickup_point.go
Normal file
62
cmd/cart/mutation_set_pickup_point.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_set_pickup_point.go
|
||||
//
|
||||
// Registers the SetPickupPoint mutation using the generic mutation registry.
|
||||
//
|
||||
// Semantics (mirrors original switch-based implementation):
|
||||
// - Locate the delivery with Id == payload.DeliveryId
|
||||
// - Set (or overwrite) its PickupPoint with the provided data
|
||||
// - Does NOT alter pricing or taxes (so no totals recalculation required)
|
||||
//
|
||||
// Validation / Error Handling:
|
||||
// - If payload is nil -> error
|
||||
// - If DeliveryId not found -> error
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on the existing expectation that higher-level mutation routing
|
||||
// serializes Apply() calls per grain; if stricter guarantees are needed,
|
||||
// a delivery-level lock could be introduced later.
|
||||
//
|
||||
// Future Extensions:
|
||||
// - Validate pickup point fields (country code, zip format, etc.)
|
||||
// - Track history / audit of pickup point changes
|
||||
// - Trigger delivery price adjustments (which would then require WithTotals()).
|
||||
|
||||
func SetPickupPoint(g *CartGrain, m *messages.SetPickupPoint) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetPickupPoint: nil payload")
|
||||
}
|
||||
|
||||
for _, d := range g.Deliveries {
|
||||
if d.Id == uint32(m.DeliveryId) {
|
||||
d.PickupPoint = &messages.PickupPoint{
|
||||
Id: m.Id,
|
||||
Name: m.Name,
|
||||
Address: m.Address,
|
||||
City: m.City,
|
||||
Zip: m.Zip,
|
||||
Country: m.Country,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
||||
}
|
||||
|
||||
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("ClearCart: nil payload")
|
||||
}
|
||||
// maybe check if payment is done?
|
||||
g.Deliveries = g.Deliveries[:0]
|
||||
g.Items = g.Items[:0]
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
677
cmd/cart/openapi.json
Normal file
677
cmd/cart/openapi.json
Normal file
@@ -0,0 +1,677 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Cart Service API",
|
||||
"description": "HTTP API for shopping cart operations (cookie-based or explicit id): retrieve cart, add/replace items, update quantity, manage deliveries.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://cart.tornberg.me",
|
||||
"description": "Production server"
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8080",
|
||||
"description": "Local development (cart API mounted under /cart)"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/cart/": {
|
||||
"get": {
|
||||
"summary": "Get (or create) current cart (cookie based)",
|
||||
"description": "Returns the current cart. If no cartid cookie is present a new cart is created and Set-Cart-Id response header plus a Set-Cookie header are sent.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Cart retrieved",
|
||||
"headers": {
|
||||
"Set-Cart-Id": {
|
||||
"description": "Returned when a new cart was created this request",
|
||||
"schema": { "type": "string" }
|
||||
},
|
||||
"X-Pod-Name": {
|
||||
"description": "Pod identifier serving the request",
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Add single SKU (body)",
|
||||
"description": "Adds (or increases quantity of) a single SKU using request body.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/AddRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Item added",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid request body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Change quantity of an item",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/ChangeQuantity" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Quantity updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid request body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "Clear cart cookie (logical cart reused only if referenced later)",
|
||||
"description": "Removes the cartid cookie by expiring it. Does not mutate server-side cart state.",
|
||||
"responses": {
|
||||
"200": { "description": "Cookie cleared (empty body)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/add/{sku}": {
|
||||
"get": {
|
||||
"summary": "Add a SKU (path)",
|
||||
"description": "Adds a single SKU with implicit quantity 1. Country inferred from Host header (-se / -no).",
|
||||
"parameters": [{ "$ref": "#/components/parameters/SkuParam" }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Item added",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/add": {
|
||||
"post": {
|
||||
"summary": "Add multiple items (append)",
|
||||
"description": "Adds multiple items to the cart without clearing existing contents.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/SetCartItems" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Items added",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/set": {
|
||||
"post": {
|
||||
"summary": "Replace cart contents",
|
||||
"description": "Clears the cart first, then adds the provided items (idempotent with respect to target set).",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/SetCartItems" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Cart replaced",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/{itemId}": {
|
||||
"delete": {
|
||||
"summary": "Remove item by line id",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "itemId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
|
||||
"description": "Internal cart line item identifier (not SKU)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Item removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Bad id" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/delivery": {
|
||||
"post": {
|
||||
"summary": "Set (add) delivery",
|
||||
"description": "Adds a delivery option referencing one or more line item ids.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/SetDeliveryRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Delivery added/updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/delivery/{deliveryId}": {
|
||||
"delete": {
|
||||
"summary": "Remove delivery",
|
||||
"parameters": [{ "$ref": "#/components/parameters/DeliveryIdParam" }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Delivery removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Bad id" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/delivery/{deliveryId}/pickupPoint": {
|
||||
"put": {
|
||||
"summary": "Set pickup point for delivery",
|
||||
"parameters": [{ "$ref": "#/components/parameters/DeliveryIdParam" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/PickupPoint" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Pickup point set",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}": {
|
||||
"get": {
|
||||
"summary": "Get cart by explicit id",
|
||||
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Cart retrieved",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid id" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Add single SKU (body) by cart id",
|
||||
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/AddRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Item added",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Change quantity (by id variant)",
|
||||
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/ChangeQuantity" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Quantity updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/add/{sku}": {
|
||||
"get": {
|
||||
"summary": "Add SKU (path) by explicit cart id",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||
{ "$ref": "#/components/parameters/SkuParam" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Item added",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid id/sku" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/{itemId}": {
|
||||
"delete": {
|
||||
"summary": "Remove item (by id variant)",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||
{
|
||||
"name": "itemId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Item removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid id" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/delivery": {
|
||||
"post": {
|
||||
"summary": "Set delivery (by id variant)",
|
||||
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/SetDeliveryRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Delivery added/updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/delivery/{deliveryId}": {
|
||||
"delete": {
|
||||
"summary": "Remove delivery (by id variant)",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||
{ "$ref": "#/components/parameters/DeliveryIdParam" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Delivery removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid ids" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/delivery/{deliveryId}/pickupPoint": {
|
||||
"put": {
|
||||
"summary": "Set pickup point (by id variant)",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||
{ "$ref": "#/components/parameters/DeliveryIdParam" }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/PickupPoint" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Pickup point updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/healthz": {
|
||||
"get": {
|
||||
"summary": "Liveness & capacity probe",
|
||||
"responses": {
|
||||
"200": { "description": "Healthy" },
|
||||
"500": { "description": "Unhealthy" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/readyz": {
|
||||
"get": {
|
||||
"summary": "Readiness probe",
|
||||
"responses": {
|
||||
"200": { "description": "Ready" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/livez": {
|
||||
"get": {
|
||||
"summary": "Liveness probe",
|
||||
"responses": {
|
||||
"200": { "description": "Alive" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/version": {
|
||||
"get": {
|
||||
"summary": "Service version",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Version string",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": { "type": "string", "example": "1.0.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"SkuParam": {
|
||||
"name": "sku",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string" }
|
||||
},
|
||||
"CartIdParam": {
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Base62 encoded cart id",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9A-Za-z]+$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"DeliveryIdParam": {
|
||||
"name": "deliveryId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"CartGrain": {
|
||||
"type": "object",
|
||||
"description": "Cart aggregate (actor state)",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Cart id (base62 encoded uint64)"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/CartItem" }
|
||||
},
|
||||
"totalPrice": { "type": "integer", "format": "int64" },
|
||||
"totalTax": { "type": "integer", "format": "int64" },
|
||||
"totalDiscount": { "type": "integer", "format": "int64" },
|
||||
"deliveries": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/CartDelivery" }
|
||||
},
|
||||
"processing": { "type": "boolean" },
|
||||
"paymentInProgress": { "type": "boolean" },
|
||||
"orderReference": { "type": "string" },
|
||||
"paymentStatus": { "type": "string" }
|
||||
},
|
||||
"required": ["id", "items", "totalPrice", "totalTax", "totalDiscount"]
|
||||
},
|
||||
"CartItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"itemId": { "type": "integer" },
|
||||
"parentId": { "type": "integer" },
|
||||
"sku": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"price": { "type": "integer", "format": "int64" },
|
||||
"totalPrice": { "type": "integer", "format": "int64" },
|
||||
"totalTax": { "type": "integer", "format": "int64" },
|
||||
"orgPrice": { "type": "integer", "format": "int64" },
|
||||
"stock": {
|
||||
"type": "integer",
|
||||
"description": "0=OutOfStock,1=LowStock,2=InStock"
|
||||
},
|
||||
"qty": { "type": "integer" },
|
||||
"tax": { "type": "integer" },
|
||||
"taxRate": { "type": "integer" },
|
||||
"brand": { "type": "string" },
|
||||
"category": { "type": "string" },
|
||||
"category2": { "type": "string" },
|
||||
"category3": { "type": "string" },
|
||||
"category4": { "type": "string" },
|
||||
"category5": { "type": "string" },
|
||||
"disclaimer": { "type": "string" },
|
||||
"sellerId": { "type": "string" },
|
||||
"sellerName": { "type": "string" },
|
||||
"type": { "type": "string", "description": "Article type" },
|
||||
"image": { "type": "string" },
|
||||
"outlet": { "type": "string", "nullable": true },
|
||||
"storeId": { "type": "string", "nullable": true }
|
||||
},
|
||||
"required": ["id", "sku", "name", "price", "qty", "tax"]
|
||||
},
|
||||
"CartDelivery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"provider": { "type": "string" },
|
||||
"price": { "type": "integer", "format": "int64" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" }
|
||||
},
|
||||
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
|
||||
},
|
||||
"required": ["id", "provider", "price", "items"]
|
||||
},
|
||||
"PickupPoint": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string", "nullable": true },
|
||||
"address": { "type": "string", "nullable": true },
|
||||
"city": { "type": "string", "nullable": true },
|
||||
"zip": { "type": "string", "nullable": true },
|
||||
"country": { "type": "string", "nullable": true }
|
||||
},
|
||||
"required": ["id"]
|
||||
},
|
||||
"AddRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
},
|
||||
"sku": { "type": "string" },
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code (inferred if omitted)"
|
||||
},
|
||||
"storeId": { "type": "string", "nullable": true }
|
||||
},
|
||||
"required": ["sku"]
|
||||
},
|
||||
"ChangeQuantity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Cart line item id"
|
||||
},
|
||||
"quantity": { "type": "integer", "format": "int32", "minimum": 0 }
|
||||
},
|
||||
"required": ["id", "quantity"]
|
||||
},
|
||||
"Item": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sku": { "type": "string" },
|
||||
"quantity": { "type": "integer", "minimum": 1 },
|
||||
"storeId": { "type": "string", "nullable": true }
|
||||
},
|
||||
"required": ["sku", "quantity"]
|
||||
},
|
||||
"SetCartItems": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"country": { "type": "string" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/Item" },
|
||||
"minItems": 0
|
||||
}
|
||||
},
|
||||
"required": ["items"]
|
||||
},
|
||||
"SetDeliveryRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": { "type": "string" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer", "format": "int64" },
|
||||
"description": "Line item ids served by this delivery"
|
||||
},
|
||||
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
|
||||
},
|
||||
"required": ["provider", "items"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [{ "name": "Cart" }, { "name": "Delivery" }, { "name": "System" }]
|
||||
}
|
||||
69
cmd/cart/openapi_embed.go
Normal file
69
cmd/cart/openapi_embed.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// openapi_embed.go: Provides embedded OpenAPI spec and helper to mount handler.
|
||||
|
||||
//go:embed openapi.json
|
||||
var openapiJSON []byte
|
||||
|
||||
var (
|
||||
openapiOnce sync.Once
|
||||
openapiETag string
|
||||
)
|
||||
|
||||
// initOpenAPIMetadata computes immutable metadata for the embedded spec.
|
||||
func initOpenAPIMetadata() {
|
||||
sum := sha256.Sum256(openapiJSON)
|
||||
openapiETag = `W/"` + hex.EncodeToString(sum[:8]) + `"` // weak ETag with first 8 bytes
|
||||
}
|
||||
|
||||
// ServeEmbeddedOpenAPI serves the embedded OpenAPI JSON spec at /openapi.json.
|
||||
// It supports GET and HEAD and implements basic ETag caching.
|
||||
func ServeEmbeddedOpenAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
openapiOnce.Do(initOpenAPIMetadata)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("ETag", openapiETag)
|
||||
|
||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
||||
if bytes.Contains([]byte(match), []byte(openapiETag)) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(openapiJSON)
|
||||
}
|
||||
|
||||
// Optional: function to access raw spec bytes programmatically.
|
||||
func OpenAPISpecBytes() []byte {
|
||||
return openapiJSON
|
||||
}
|
||||
|
||||
// Optional: function to access current ETag.
|
||||
func OpenAPIETag() string {
|
||||
openapiOnce.Do(initOpenAPIMetadata)
|
||||
return openapiETag
|
||||
}
|
||||
532
cmd/cart/pool-server.go
Normal file
532
cmd/cart/pool-server.go
Normal file
@@ -0,0 +1,532 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||
"github.com/gogo/protobuf/proto"
|
||||
)
|
||||
|
||||
type PoolServer struct {
|
||||
actor.GrainPool[*CartGrain]
|
||||
pod_name string
|
||||
klarnaClient *KlarnaClient
|
||||
}
|
||||
|
||||
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
|
||||
return &PoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
klarnaClient: klarnaClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyLocal(id CartId, mutation ...proto.Message) (*actor.MutationResult[*CartGrain], error) {
|
||||
return s.Apply(uint64(id), mutation...)
|
||||
}
|
||||
|
||||
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
grain, err := s.Get(uint64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, grain)
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
sku := r.PathValue("sku")
|
||||
msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.ApplyLocal(id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||
if result == nil {
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
return nil
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(w)
|
||||
err := enc.Encode(result)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
itemIdString := r.PathValue("itemId")
|
||||
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: uint32(itemId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
type SetDeliveryRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Items []uint32 `json:"items"`
|
||||
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
delivery := SetDeliveryRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&delivery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.ApplyLocal(id, &messages.SetDelivery{
|
||||
Provider: delivery.Provider,
|
||||
Items: delivery.Items,
|
||||
PickupPoint: delivery.PickupPoint,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pickupPoint := messages.PickupPoint{}
|
||||
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{
|
||||
DeliveryId: uint32(deliveryId),
|
||||
Id: pickupPoint.Id,
|
||||
Name: pickupPoint.Name,
|
||||
Address: pickupPoint.Address,
|
||||
City: pickupPoint.City,
|
||||
Zip: pickupPoint.Zip,
|
||||
Country: pickupPoint.Country,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
changeQuantity := messages.ChangeQuantity{}
|
||||
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, &changeQuantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Sku string `json:"sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
StoreId *string `json:"storeId,omitempty"`
|
||||
}
|
||||
|
||||
type SetCartItems struct {
|
||||
Country string `json:"country"`
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
func getMultipleAddMessages(items []Item, country string) []proto.Message {
|
||||
wg := sync.WaitGroup{}
|
||||
mu := sync.Mutex{}
|
||||
msgs := make([]proto.Message, 0, len(items))
|
||||
for _, itm := range items {
|
||||
wg.Go(
|
||||
func() {
|
||||
msg, err := GetItemAddMessage(itm.Sku, itm.Quantity, country, itm.StoreId)
|
||||
if err != nil {
|
||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
msgs = append(msgs, msg)
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
return msgs
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
setCartItems := SetCartItems{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
|
||||
msgs = append(msgs, &messages.ClearCartRequest{})
|
||||
msgs = append(msgs, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
|
||||
|
||||
reply, err := s.ApplyLocal(id, msgs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
setCartItems := SetCartItems{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reply, err := s.ApplyLocal(id, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
type AddRequest struct {
|
||||
Sku string `json:"sku"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
Country string `json:"country"`
|
||||
StoreId *string `json:"storeId"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
addRequest := AddRequest{Quantity: 1}
|
||||
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := GetItemAddMessage(addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
// func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
// orderId := r.PathValue("orderId")
|
||||
// if orderId == "" {
|
||||
// return fmt.Errorf("orderId is empty")
|
||||
// }
|
||||
// order, err := KlarnaInstance.GetOrder(orderId)
|
||||
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// w.Header().Set("Content-Type", "application/json")
|
||||
// w.Header().Set("X-Pod-Name", s.pod_name)
|
||||
// w.Header().Set("Cache-Control", "no-cache")
|
||||
// w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
// return json.NewEncoder(w).Encode(order)
|
||||
// }
|
||||
|
||||
func getCurrency(country string) string {
|
||||
if country == "no" {
|
||||
return "NOK"
|
||||
}
|
||||
return "SEK"
|
||||
}
|
||||
|
||||
func getLocale(country string) string {
|
||||
if country == "no" {
|
||||
return "nb-no"
|
||||
}
|
||||
return "sv-se"
|
||||
}
|
||||
|
||||
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
|
||||
country := getCountryFromHost(host)
|
||||
meta := &CheckoutMeta{
|
||||
Terms: fmt.Sprintf("https://%s/terms", host),
|
||||
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
|
||||
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
|
||||
Validation: fmt.Sprintf("https://%s/validate", host),
|
||||
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
|
||||
Country: country,
|
||||
Currency: getCurrency(country),
|
||||
Locale: getLocale(country),
|
||||
}
|
||||
|
||||
// Get current grain state (may be local or remote)
|
||||
grain, err := s.Get(uint64(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build pure checkout payload
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if grain.OrderReference != "" {
|
||||
return s.klarnaClient.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
|
||||
} else {
|
||||
return s.klarnaClient.CreateOrder(bytes.NewReader(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*actor.MutationResult[*CartGrain], error) {
|
||||
// Persist initialization state via mutation (best-effort)
|
||||
return s.ApplyLocal(id, &messages.InitializeCheckout{
|
||||
OrderId: klarnaOrder.ID,
|
||||
Status: klarnaOrder.Status,
|
||||
PaymentInProgress: true,
|
||||
})
|
||||
}
|
||||
|
||||
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// s.ApplyCheckoutStarted(klarnaOrder, id)
|
||||
|
||||
// w.Header().Set("Content-Type", "application/json")
|
||||
// return json.NewEncoder(w).Encode(klarnaOrder)
|
||||
// }
|
||||
//
|
||||
|
||||
func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id CartId
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil || cookie.Value == "" {
|
||||
id = MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
parsed, ok := ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
id = MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
id = parsed
|
||||
}
|
||||
}
|
||||
|
||||
err = fn(id, w, r)
|
||||
if err != nil {
|
||||
log.Printf("Server error, not remote error: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
|
||||
|
||||
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
// Clear cart cookie (breaking change: do not issue a new legacy id here)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id CartId
|
||||
raw := r.PathValue("id")
|
||||
// If no id supplied, generate a new one
|
||||
if raw == "" {
|
||||
id := MustNewCartId()
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
// Parse base62 cart id
|
||||
if parsedId, ok := ParseCartId(raw); !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("cart id is invalid"))
|
||||
return
|
||||
} else {
|
||||
id = parsedId
|
||||
}
|
||||
}
|
||||
|
||||
err := fn(id, w, r)
|
||||
if err != nil {
|
||||
log.Printf("Server error, not remote error: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
return func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
|
||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
|
||||
if err == nil && handled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fn(w, r, cartId)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
type AddVoucherRequest struct {
|
||||
VoucherCode string `json:"code"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
data := &AddVoucherRequest{}
|
||||
json.NewDecoder(r.Body).Decode(data)
|
||||
v := voucher.Service{}
|
||||
msg, err := v.GetVoucher(data.VoucherCode)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(cartId, msg)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
s.WriteResult(w, reply)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
|
||||
idStr := r.PathValue("voucherId")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(cartId, &messages.RemoveVoucher{Id: uint32(id)})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
s.WriteResult(w, reply)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) Serve() *http.ServeMux {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||
mux.HandleFunc("GET /add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||
mux.HandleFunc("POST /add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
|
||||
mux.HandleFunc("POST /", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||
mux.HandleFunc("POST /set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
|
||||
mux.HandleFunc("DELETE /{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||
mux.HandleFunc("PUT /", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||
mux.HandleFunc("DELETE /", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
|
||||
mux.HandleFunc("POST /delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||
mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||
mux.HandleFunc("PUT /voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
mux.HandleFunc("DELETE /voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
|
||||
//mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||
//mux.HandleFunc("GET /confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||
|
||||
mux.HandleFunc("GET /byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||
mux.HandleFunc("GET /byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||
mux.HandleFunc("POST /byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||
mux.HandleFunc("PUT /byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||
mux.HandleFunc("POST /byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||
mux.HandleFunc("PUT /byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
//mux.HandleFunc("GET /byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||
//mux.HandleFunc("GET /byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||
|
||||
return mux
|
||||
}
|
||||
131
cmd/cart/price.go
Normal file
131
cmd/cart/price.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func GetTaxAmount(total int64, tax int) int64 {
|
||||
taxD := 10000 / float64(tax)
|
||||
return int64(float64(total) / float64((1 + taxD)))
|
||||
}
|
||||
|
||||
type Price struct {
|
||||
IncVat int64 `json:"incVat"`
|
||||
VatRates map[float32]int64 `json:"vat,omitempty"`
|
||||
}
|
||||
|
||||
func NewPrice() *Price {
|
||||
return &Price{
|
||||
IncVat: 0,
|
||||
VatRates: make(map[float32]int64),
|
||||
}
|
||||
}
|
||||
|
||||
func NewPriceFromIncVat(incVat int64, taxRate float32) *Price {
|
||||
tax := GetTaxAmount(incVat, int(taxRate*100))
|
||||
return &Price{
|
||||
IncVat: incVat,
|
||||
VatRates: map[float32]int64{
|
||||
taxRate: tax,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Price) ValueExVat() int64 {
|
||||
exVat := p.IncVat
|
||||
for _, amount := range p.VatRates {
|
||||
exVat -= amount
|
||||
}
|
||||
return exVat
|
||||
}
|
||||
|
||||
func (p *Price) TotalVat() int64 {
|
||||
total := int64(0)
|
||||
for _, amount := range p.VatRates {
|
||||
total += amount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func MultiplyPrice(p Price, qty int64) *Price {
|
||||
ret := &Price{
|
||||
IncVat: p.IncVat * qty,
|
||||
VatRates: make(map[float32]int64),
|
||||
}
|
||||
for rate, amount := range p.VatRates {
|
||||
ret.VatRates[rate] = amount * qty
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p *Price) Multiply(qty int64) {
|
||||
p.IncVat *= qty
|
||||
for rate, amount := range p.VatRates {
|
||||
p.VatRates[rate] = amount * qty
|
||||
}
|
||||
}
|
||||
|
||||
func (p Price) MarshalJSON() ([]byte, error) {
|
||||
// Build a stable wire format without calling Price.MarshalJSON recursively
|
||||
exVat := p.ValueExVat()
|
||||
var vat map[string]int64
|
||||
if len(p.VatRates) > 0 {
|
||||
vat = make(map[string]int64, len(p.VatRates))
|
||||
for rate, amount := range p.VatRates {
|
||||
// Rely on default formatting that trims trailing zeros for whole numbers
|
||||
// Using %g could output scientific notation for large numbers; float32 rates here are small.
|
||||
key := trimFloat(rate)
|
||||
vat[key] = amount
|
||||
}
|
||||
}
|
||||
type wire struct {
|
||||
ExVat int64 `json:"exVat"`
|
||||
IncVat int64 `json:"incVat"`
|
||||
Vat map[string]int64 `json:"vat,omitempty"`
|
||||
}
|
||||
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
|
||||
}
|
||||
|
||||
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
|
||||
// unnecessary decimals ("25", "12.5").
|
||||
func trimFloat(f float32) string {
|
||||
// Convert via FormatFloat then trim trailing zeros and dot.
|
||||
s := strconv.FormatFloat(float64(f), 'f', -1, 32)
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Price) Add(price Price) {
|
||||
p.IncVat += price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
p.VatRates[rate] += amount
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Price) Subtract(price Price) {
|
||||
p.IncVat -= price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
p.VatRates[rate] -= amount
|
||||
}
|
||||
}
|
||||
|
||||
func SumPrices(prices ...Price) *Price {
|
||||
if len(prices) == 0 {
|
||||
return NewPrice()
|
||||
}
|
||||
|
||||
aggregated := NewPrice()
|
||||
|
||||
for _, price := range prices {
|
||||
aggregated.IncVat += price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
aggregated.VatRates[rate] += amount
|
||||
}
|
||||
}
|
||||
|
||||
if len(aggregated.VatRates) == 0 {
|
||||
aggregated.VatRates = nil
|
||||
}
|
||||
|
||||
return aggregated
|
||||
}
|
||||
135
cmd/cart/price_test.go
Normal file
135
cmd/cart/price_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPriceMarshalJSON(t *testing.T) {
|
||||
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
|
||||
// ExVat = 13700 - (2500+1200) = 10000
|
||||
data, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
// Unmarshal into a generic struct to validate fields
|
||||
var out struct {
|
||||
ExVat int64 `json:"exVat"`
|
||||
IncVat int64 `json:"incVat"`
|
||||
Vat map[string]int64 `json:"vat"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if out.ExVat != 10000 {
|
||||
t.Fatalf("expected exVat 10000 got %d", out.ExVat)
|
||||
}
|
||||
if out.IncVat != 13700 {
|
||||
t.Fatalf("expected incVat 13700 got %d", out.IncVat)
|
||||
}
|
||||
if out.Vat["25"] != 2500 || out.Vat["12"] != 1200 {
|
||||
t.Fatalf("unexpected vat map: %#v", out.Vat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPriceFromIncVat(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1000, 0.25)
|
||||
if p.IncVat != 1000 {
|
||||
t.Fatalf("expected IncVat %d got %d", 1000, p.IncVat)
|
||||
}
|
||||
if p.VatRates[25] != 250 {
|
||||
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
|
||||
}
|
||||
if p.ValueExVat() != 750 {
|
||||
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumPrices(t *testing.T) {
|
||||
// We'll construct prices via raw struct since constructor expects tax math.
|
||||
// IncVat already includes vat portions.
|
||||
a := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}} // ex=1000
|
||||
b := Price{IncVat: 2740, VatRates: map[float32]int64{25: 500, 12: 240}} // ex=2000
|
||||
c := Price{IncVat: 0, VatRates: nil}
|
||||
|
||||
sum := SumPrices(a, b, c)
|
||||
|
||||
if sum.IncVat != 3990 { // 1250+2740
|
||||
t.Fatalf("expected incVat 3990 got %d", sum.IncVat)
|
||||
}
|
||||
if len(sum.VatRates) != 2 {
|
||||
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
|
||||
}
|
||||
if sum.VatRates[25] != 750 {
|
||||
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[25])
|
||||
}
|
||||
if sum.VatRates[12] != 240 {
|
||||
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[12])
|
||||
}
|
||||
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
|
||||
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumPricesEmpty(t *testing.T) {
|
||||
sum := SumPrices()
|
||||
if sum.IncVat != 0 || sum.VatRates == nil { // constructor sets empty map
|
||||
t.Fatalf("expected zero price got %#v", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiplyPriceFunction(t *testing.T) {
|
||||
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
||||
multiplied := MultiplyPrice(base, 3)
|
||||
if multiplied.IncVat != 1250*3 {
|
||||
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
|
||||
}
|
||||
if multiplied.VatRates[25] != 250*3 {
|
||||
t.Fatalf("expected VAT 25 rate %d got %d", 250*3, multiplied.VatRates[25])
|
||||
}
|
||||
if multiplied.ValueExVat() != (1250-250)*3 {
|
||||
t.Fatalf("expected exVat %d got %d", (1250-250)*3, multiplied.ValueExVat())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceAddSubtract(t *testing.T) {
|
||||
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
|
||||
b := Price{IncVat: 500, VatRates: map[float32]int64{25: 100, 12: 54}}
|
||||
|
||||
acc := NewPrice()
|
||||
acc.Add(a)
|
||||
acc.Add(b)
|
||||
|
||||
if acc.IncVat != 1500 {
|
||||
t.Fatalf("expected IncVat 1500 got %d", acc.IncVat)
|
||||
}
|
||||
if acc.VatRates[25] != 300 || acc.VatRates[12] != 54 {
|
||||
t.Fatalf("unexpected VAT map: %#v", acc.VatRates)
|
||||
}
|
||||
|
||||
// Subtract b then a returns to zero
|
||||
acc.Subtract(b)
|
||||
acc.Subtract(a)
|
||||
if acc.IncVat != 0 {
|
||||
t.Fatalf("expected IncVat 0 got %d", acc.IncVat)
|
||||
}
|
||||
if len(acc.VatRates) != 2 || acc.VatRates[25] != 0 || acc.VatRates[12] != 0 {
|
||||
t.Fatalf("expected zeroed vat rates got %#v", acc.VatRates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceMultiplyMethod(t *testing.T) {
|
||||
p := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
|
||||
// Value before multiply
|
||||
exBefore := p.ValueExVat()
|
||||
p.Multiply(2)
|
||||
if p.IncVat != 4000 {
|
||||
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
|
||||
}
|
||||
if p.VatRates[25] != 800 {
|
||||
t.Fatalf("expected VAT 800 got %d", p.VatRates[25])
|
||||
}
|
||||
if p.ValueExVat() != exBefore*2 {
|
||||
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
|
||||
}
|
||||
}
|
||||
121
cmd/cart/product-fetcher.go
Normal file
121
cmd/cart/product-fetcher.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"github.com/matst80/slask-finder/pkg/index"
|
||||
)
|
||||
|
||||
// TODO make this configurable
|
||||
func getBaseUrl(country string) string {
|
||||
// if country == "se" {
|
||||
// return "http://s10n-se:8080"
|
||||
// }
|
||||
if country == "no" {
|
||||
return "http://s10n-no.s10n:8080"
|
||||
}
|
||||
if country == "se" {
|
||||
return "http://s10n-se.s10n:8080"
|
||||
}
|
||||
return "http://localhost:8082"
|
||||
}
|
||||
|
||||
func FetchItem(sku string, country string) (*index.DataItem, error) {
|
||||
baseUrl := getBaseUrl(country)
|
||||
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var item index.DataItem
|
||||
err = json.NewDecoder(res.Body).Decode(&item)
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func GetItemAddMessage(sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
||||
item, err := FetchItem(sku, country)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ToItemAddMessage(item, storeId, qty, country), nil
|
||||
}
|
||||
|
||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) *messages.AddItem {
|
||||
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||
|
||||
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stock := StockStatus(0)
|
||||
centralStockValue, ok := item.GetStringFieldValue(3)
|
||||
if storeId == nil {
|
||||
if ok {
|
||||
pureNumber := strings.Replace(centralStockValue, "+", "", -1)
|
||||
if centralStock, err := strconv.ParseInt(pureNumber, 10, 64); err == nil {
|
||||
stock = StockStatus(centralStock)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
storeStock, ok := item.Stock.GetStock()[*storeId]
|
||||
if ok {
|
||||
stock = StockStatus(storeStock)
|
||||
}
|
||||
}
|
||||
|
||||
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
||||
var outlet *string
|
||||
if ok {
|
||||
outlet = &outletGrade
|
||||
}
|
||||
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||
|
||||
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||
|
||||
return &messages.AddItem{
|
||||
ItemId: uint32(item.Id),
|
||||
Quantity: int32(qty),
|
||||
Price: int64(price),
|
||||
OrgPrice: int64(orgPrice),
|
||||
Sku: item.GetSku(),
|
||||
Name: item.Title,
|
||||
Image: item.Img,
|
||||
Stock: int32(stock),
|
||||
Brand: brand,
|
||||
Category: category,
|
||||
Category2: category2,
|
||||
Category3: category3,
|
||||
Category4: category4,
|
||||
Category5: category5,
|
||||
Tax: getTax(articleType),
|
||||
SellerId: sellerId,
|
||||
SellerName: sellerName,
|
||||
ArticleType: articleType,
|
||||
Disclaimer: item.Disclaimer,
|
||||
Country: country,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
}
|
||||
}
|
||||
|
||||
func getTax(articleType string) int32 {
|
||||
switch articleType {
|
||||
case "ZDIE":
|
||||
return 600
|
||||
default:
|
||||
return 2500
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user