package cart import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "time" "gorm.io/gorm" ) type Summary struct { ID int64 TotalItems int64 } type Page struct { ID int64 Items []Item TotalItems int64 Subtotal float64 SubtotalTaxIncl float64 } type Item struct { ProductID int64 ProductAttributeID int64 CustomizationID int64 Name string Slug string CategoryPath string EAN13 string CoverImageID sql.NullInt64 `gorm:"column:cover_image_id"` ImageURL string `gorm:"-"` Quantity int64 UnitPrice float64 UnitPriceTaxIncl float64 `gorm:"column:unit_price_tax_incl"` LineTotal float64 LineTotalTaxIncl float64 `gorm:"column:line_total_tax_incl"` TaxRate float64 `gorm:"column:tax_rate"` CurrencyID int64 `gorm:"column:currency_id"` CurrencyCode string `gorm:"column:currency_code"` CurrencySign string `gorm:"column:currency_sign"` ConversionRate float64 `gorm:"column:conversion_rate"` URL string `gorm:"-"` Attributes []ItemAttribute `gorm:"-"` } type ItemAttribute struct { Group string Value string } type MutationInput struct { CartID int64 ProductID int64 ProductAttributeID int64 CustomizationID int64 Quantity int64 CustomerID int64 GuestID int64 LanguageID int64 CurrencyID int64 ShopID int64 } type MutationResult struct { CartID int64 LineQuantity int64 TotalItems int64 CreatedCart bool } type Service struct { db *gorm.DB prefix string } func NewService(db *gorm.DB, prefix string) *Service { return &Service{db: db, prefix: prefix} } func (s *Service) SummaryByID(ctx context.Context, cartID int64) (*Summary, error) { var summary Summary query := fmt.Sprintf("SELECT id_cart AS id, COALESCE(SUM(quantity), 0) AS total_items FROM %scart_product WHERE id_cart = ? GROUP BY id_cart", s.prefix) result := s.db.WithContext(ctx).Raw(query, cartID).Scan(&summary) if result.Error != nil { return nil, result.Error } if result.RowsAffected == 0 { return &Summary{ID: cartID}, nil } return &summary, nil } func (s *Service) PageByID(ctx context.Context, cartID, languageID, shopID, currencyID int64) (*Page, error) { if s == nil || s.db == nil { return nil, errors.New("prestashop cart service is not initialized") } if cartID == 0 { return &Page{}, nil } if languageID == 0 { languageID = 1 } if shopID == 0 { shopID = 1 } if currencyID == 0 { currencyID = 1 } query := fmt.Sprintf(` SELECT cp.id_product AS product_id, COALESCE(cp.id_product_attribute, 0) AS product_attribute_id, COALESCE(cp.id_customization, 0) AS customization_id, pl.name AS name, pl.link_rewrite AS slug, cl.link_rewrite AS category_path, p.ean13 AS ean13, COALESCE(combination_image.id_image, i.id_image) AS cover_image_id, cp.quantity AS quantity, ((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate) AS unit_price, (((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS unit_price_tax_incl, (cp.quantity * ((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate)) AS line_total, (cp.quantity * (((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100))) AS line_total_tax_incl, COALESCE(tax_data.tax_rate, 0) AS tax_rate, curr.id_currency AS currency_id, curr.iso_code AS currency_code, curr.sign AS currency_sign, curr.conversion_rate AS conversion_rate FROM %scart_product cp JOIN %sproduct p ON p.id_product = cp.id_product JOIN %sproduct_shop product_shop ON product_shop.id_product = cp.id_product AND product_shop.id_shop = cp.id_shop JOIN %sproduct_lang pl ON pl.id_product = cp.id_product AND pl.id_lang = ? AND pl.id_shop = ? LEFT JOIN %simage i ON i.id_product = cp.id_product AND i.cover = 1 LEFT JOIN ( SELECT pai.id_product_attribute, MIN(pai.id_image) AS id_image FROM %sproduct_attribute_image pai GROUP BY pai.id_product_attribute ) combination_image ON combination_image.id_product_attribute = cp.id_product_attribute LEFT JOIN %sproduct_attribute_shop product_attribute_shop ON product_attribute_shop.id_product_attribute = cp.id_product_attribute AND product_attribute_shop.id_shop = cp.id_shop JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0 LEFT JOIN ( SELECT tr.id_tax_rules_group, SUM(t.rate) AS tax_rate FROM %stax_rule tr JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1 GROUP BY tr.id_tax_rules_group ) tax_data ON tax_data.id_tax_rules_group = product_shop.id_tax_rules_group LEFT JOIN %scategory_lang cl ON cl.id_category = product_shop.id_category_default AND cl.id_lang = pl.id_lang AND cl.id_shop = pl.id_shop WHERE cp.id_cart = ? AND product_shop.active = 1 ORDER BY cp.date_add ASC, cp.id_product ASC, cp.id_product_attribute ASC `, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix) var items []Item if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), languageID, shopID, currencyID, cartID).Scan(&items).Error; err != nil { return nil, err } page := &Page{ID: cartID, Items: items} if err := s.loadItemAttributes(ctx, languageID, &page.Items); err != nil { return nil, err } for _, item := range items { page.TotalItems += item.Quantity page.Subtotal += item.LineTotal page.SubtotalTaxIncl += item.LineTotalTaxIncl } return page, nil } func (s *Service) loadItemAttributes(ctx context.Context, languageID int64, items *[]Item) error { if items == nil || len(*items) == 0 { return nil } attributeIDs := make([]int64, 0, len(*items)) indexByAttributeID := make(map[int64][]int) for i, item := range *items { if item.ProductAttributeID == 0 { continue } if _, exists := indexByAttributeID[item.ProductAttributeID]; !exists { attributeIDs = append(attributeIDs, item.ProductAttributeID) } indexByAttributeID[item.ProductAttributeID] = append(indexByAttributeID[item.ProductAttributeID], i) } if len(attributeIDs) == 0 { return nil } type attributeRow struct { ProductAttributeID int64 `gorm:"column:id_product_attribute"` GroupName string `gorm:"column:group_name"` AttributeName string `gorm:"column:attribute_name"` } query := fmt.Sprintf(` SELECT pac.id_product_attribute, agl.public_name AS group_name, al.name AS attribute_name FROM %sproduct_attribute_combination pac JOIN %sattribute a ON a.id_attribute = pac.id_attribute JOIN %sattribute_lang al ON al.id_attribute = a.id_attribute AND al.id_lang = ? JOIN %sattribute_group ag ON ag.id_attribute_group = a.id_attribute_group JOIN %sattribute_group_lang agl ON agl.id_attribute_group = ag.id_attribute_group AND agl.id_lang = ? WHERE pac.id_product_attribute IN ? ORDER BY pac.id_product_attribute ASC, ag.position ASC, a.position ASC, al.name ASC `, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix) rows := make([]attributeRow, 0) if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), languageID, languageID, attributeIDs).Scan(&rows).Error; err != nil { return err } for _, row := range rows { indices := indexByAttributeID[row.ProductAttributeID] for _, idx := range indices { (*items)[idx].Attributes = append((*items)[idx].Attributes, ItemAttribute{ Group: strings.TrimSpace(row.GroupName), Value: strings.TrimSpace(row.AttributeName), }) } } return nil } func (s *Service) AddProduct(ctx context.Context, input MutationInput) (*MutationResult, error) { return s.mutateProduct(ctx, mutationAdd, input) } func (s *Service) UpdateProduct(ctx context.Context, input MutationInput) (*MutationResult, error) { return s.mutateProduct(ctx, mutationSet, input) } func (s *Service) DeleteProduct(ctx context.Context, input MutationInput) (*MutationResult, error) { return s.mutateProduct(ctx, mutationDelete, input) } type mutationMode string const ( mutationAdd mutationMode = "add" mutationSet mutationMode = "set" mutationDelete mutationMode = "delete" ) type productLine struct { CartID int64 `gorm:"column:id_cart"` ProductID int64 `gorm:"column:id_product"` ProductAttributeID int64 `gorm:"column:id_product_attribute"` CustomizationID int64 `gorm:"column:id_customization"` AddressDeliveryID int64 `gorm:"column:id_address_delivery"` Quantity int64 `gorm:"column:quantity"` } type cartContext struct { ID int64 ShopID int64 `gorm:"column:id_shop"` ShopGroupID int64 `gorm:"column:id_shop_group"` CustomerID int64 `gorm:"column:id_customer"` GuestID int64 `gorm:"column:id_guest"` LanguageID int64 `gorm:"column:id_lang"` CurrencyID int64 `gorm:"column:id_currency"` AddressDeliveryID int64 `gorm:"column:id_address_delivery"` AddressInvoiceID int64 `gorm:"column:id_address_invoice"` SecureKey string } func (s *Service) mutateProduct(ctx context.Context, mode mutationMode, input MutationInput) (*MutationResult, error) { if s == nil || s.db == nil { return nil, errors.New("prestashop cart service is not initialized") } if input.ProductID == 0 { return nil, errors.New("product id is required") } if mode != mutationDelete && input.Quantity <= 0 { return nil, errors.New("quantity must be positive") } if input.ShopID == 0 { input.ShopID = 1 } if input.LanguageID == 0 { input.LanguageID = 1 } if input.CurrencyID == 0 { input.CurrencyID = 1 } var result MutationResult err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { cart, created, err := s.ensureCart(tx, input) if err != nil { return err } result.CartID = cart.ID result.CreatedCart = created attributeID, err := s.resolveProductAttributeID(tx, input.ProductID, input.ProductAttributeID, cart.ShopID) if err != nil { return err } input.ProductAttributeID = attributeID if err := s.ensureProductExists(tx, input.ProductID, cart.ShopID); err != nil { return err } line, err := s.loadProductLine(tx, cart.ID, input.ProductID, input.ProductAttributeID, input.CustomizationID) if err != nil { return err } switch mode { case mutationDelete: if err := s.deleteProductLine(tx, cart.ID, input.ProductID, input.ProductAttributeID, input.CustomizationID); err != nil { return err } result.LineQuantity = 0 case mutationAdd: addressID := cart.AddressDeliveryID if line != nil && line.AddressDeliveryID != 0 { addressID = line.AddressDeliveryID } newQty := input.Quantity if line != nil { newQty += line.Quantity if err := s.updateProductLineQuantity(tx, line, newQty); err != nil { return err } } else { if err := s.insertProductLine(tx, cart, input.ProductID, input.ProductAttributeID, input.CustomizationID, addressID, newQty); err != nil { return err } } result.LineQuantity = newQty case mutationSet: if input.Quantity == 0 { if err := s.deleteProductLine(tx, cart.ID, input.ProductID, input.ProductAttributeID, input.CustomizationID); err != nil { return err } result.LineQuantity = 0 } else if line != nil { if err := s.updateProductLineQuantity(tx, line, input.Quantity); err != nil { return err } result.LineQuantity = input.Quantity } else { if err := s.insertProductLine(tx, cart, input.ProductID, input.ProductAttributeID, input.CustomizationID, cart.AddressDeliveryID, input.Quantity); err != nil { return err } result.LineQuantity = input.Quantity } default: return fmt.Errorf("unsupported cart mutation %q", mode) } if err := s.touchCart(tx, cart.ID); err != nil { return err } summary, err := s.summaryByIDWithDB(tx, cart.ID) if err != nil { return err } result.TotalItems = summary.TotalItems return nil }) if err != nil { return nil, err } return &result, nil } func (s *Service) ensureCart(tx *gorm.DB, input MutationInput) (*cartContext, bool, error) { if input.CartID != 0 { cart, err := s.loadCart(tx, input.CartID) if err == nil { return cart, false, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, err } } ctx := cartContext{ ShopID: input.ShopID, CustomerID: input.CustomerID, GuestID: input.GuestID, LanguageID: input.LanguageID, CurrencyID: input.CurrencyID, } shopGroupID, err := s.loadShopGroupID(tx, ctx.ShopID) if err != nil { return nil, false, err } ctx.ShopGroupID = shopGroupID if ctx.CustomerID != 0 { ctx.AddressDeliveryID, ctx.AddressInvoiceID, err = s.loadCustomerAddressIDs(tx, ctx.CustomerID) if err != nil { return nil, false, err } ctx.SecureKey, err = s.loadCustomerSecureKey(tx, ctx.CustomerID) if err != nil { return nil, false, err } } cartID, err := s.insertCart(tx, &ctx) if err != nil { return nil, false, err } ctx.ID = cartID return &ctx, true, nil } func (s *Service) loadCart(tx *gorm.DB, cartID int64) (*cartContext, error) { var cart cartContext query := fmt.Sprintf(` SELECT id_cart AS id, COALESCE(id_shop, 0) AS id_shop, COALESCE(id_shop_group, 0) AS id_shop_group, COALESCE(id_customer, 0) AS id_customer, COALESCE(id_guest, 0) AS id_guest, COALESCE(id_lang, 0) AS id_lang, COALESCE(id_currency, 0) AS id_currency, COALESCE(id_address_delivery, 0) AS id_address_delivery, COALESCE(id_address_invoice, 0) AS id_address_invoice, COALESCE(secure_key, '') AS secure_key FROM %scart WHERE id_cart = ? LIMIT 1 `, s.prefix) err := tx.Raw(strings.TrimSpace(query), cartID).Scan(&cart).Error if err != nil { return nil, err } if cart.ID == 0 { return nil, gorm.ErrRecordNotFound } return &cart, nil } func (s *Service) loadShopGroupID(tx *gorm.DB, shopID int64) (int64, error) { var row struct { ShopGroupID int64 `gorm:"column:id_shop_group"` } query := fmt.Sprintf("SELECT id_shop_group FROM %sshop WHERE id_shop = ? LIMIT 1", s.prefix) if err := tx.Raw(query, shopID).Scan(&row).Error; err != nil { return 0, err } if row.ShopGroupID == 0 { return 0, fmt.Errorf("prestashop shop %d not found", shopID) } return row.ShopGroupID, nil } func (s *Service) loadCustomerAddressIDs(tx *gorm.DB, customerID int64) (int64, int64, error) { var row struct { ID int64 `gorm:"column:id_address"` } query := fmt.Sprintf(` SELECT id_address FROM %saddress WHERE id_customer = ? AND deleted = 0 ORDER BY id_address ASC LIMIT 1 `, s.prefix) if err := tx.Raw(strings.TrimSpace(query), customerID).Scan(&row).Error; err != nil { return 0, 0, err } return row.ID, row.ID, nil } func (s *Service) loadCustomerSecureKey(tx *gorm.DB, customerID int64) (string, error) { var row struct { SecureKey string `gorm:"column:secure_key"` } query := fmt.Sprintf("SELECT COALESCE(secure_key, '') AS secure_key FROM %scustomer WHERE id_customer = ? LIMIT 1", s.prefix) if err := tx.Raw(query, customerID).Scan(&row).Error; err != nil { return "", err } return row.SecureKey, nil } func (s *Service) insertCart(tx *gorm.DB, cart *cartContext) (int64, error) { available, err := s.tableColumns(tx, s.prefix+"cart") if err != nil { return 0, err } now := time.Now().UTC().Format("2006-01-02 15:04:05") columns := make([]string, 0, 16) values := make([]any, 0, 16) add := func(name string, value any) { if available[name] { columns = append(columns, name) values = append(values, value) } } add("id_shop_group", cart.ShopGroupID) add("id_shop", cart.ShopID) add("id_address_delivery", cart.AddressDeliveryID) add("id_address_invoice", cart.AddressInvoiceID) add("id_carrier", 0) add("id_currency", cart.CurrencyID) add("id_customer", cart.CustomerID) add("id_guest", cart.GuestID) add("id_lang", cart.LanguageID) add("recyclable", 0) add("gift", 0) add("gift_message", "") add("mobile_theme", 0) add("delivery_option", "") add("secure_key", cart.SecureKey) add("allow_seperated_package", 0) add("date_add", now) add("date_upd", now) if err := tx.Exec(insertQuery(s.prefix+"cart", columns), values...).Error; err != nil { return 0, err } var row struct { ID int64 `gorm:"column:id"` } if err := tx.Raw("SELECT LAST_INSERT_ID() AS id").Scan(&row).Error; err != nil { return 0, err } if row.ID == 0 { return 0, errors.New("cart insert did not return an id") } return row.ID, nil } func (s *Service) resolveProductAttributeID(tx *gorm.DB, productID, productAttributeID, shopID int64) (int64, error) { if productAttributeID != 0 { var row struct { ID int64 `gorm:"column:id_product_attribute"` } query := fmt.Sprintf(` SELECT pa.id_product_attribute FROM %sproduct_attribute pa WHERE pa.id_product_attribute = ? AND pa.id_product = ? LIMIT 1 `, s.prefix) if err := tx.Raw(strings.TrimSpace(query), productAttributeID, productID).Scan(&row).Error; err != nil { return 0, err } if row.ID == 0 { return 0, fmt.Errorf("product attribute %d does not belong to product %d", productAttributeID, productID) } return row.ID, nil } var row struct { ID int64 `gorm:"column:id_product_attribute"` } query := fmt.Sprintf(` SELECT pa.id_product_attribute FROM %sproduct_attribute pa LEFT JOIN %sproduct_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ? WHERE pa.id_product = ? ORDER BY CASE WHEN COALESCE(pas.default_on, 0) = 1 THEN 0 ELSE 1 END, pa.id_product_attribute ASC LIMIT 1 `, s.prefix, s.prefix) if err := tx.Raw(strings.TrimSpace(query), shopID, productID).Scan(&row).Error; err != nil { return 0, err } return row.ID, nil } func (s *Service) ensureProductExists(tx *gorm.DB, productID, shopID int64) error { var row struct { ID int64 `gorm:"column:id_product"` } query := fmt.Sprintf(` SELECT p.id_product FROM %sproduct p JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ? WHERE p.id_product = ? LIMIT 1 `, s.prefix, s.prefix) if err := tx.Raw(strings.TrimSpace(query), shopID, productID).Scan(&row).Error; err != nil { return err } if row.ID == 0 { return gorm.ErrRecordNotFound } return nil } func (s *Service) loadProductLine(tx *gorm.DB, cartID, productID, productAttributeID, customizationID int64) (*productLine, error) { var line productLine query := fmt.Sprintf(` SELECT id_cart, id_product, COALESCE(id_product_attribute, 0) AS id_product_attribute, COALESCE(id_customization, 0) AS id_customization, COALESCE(id_address_delivery, 0) AS id_address_delivery, quantity FROM %scart_product WHERE id_cart = ? AND id_product = ? AND COALESCE(id_product_attribute, 0) = ? AND COALESCE(id_customization, 0) = ? LIMIT 1 `, s.prefix) if err := tx.Raw(strings.TrimSpace(query), cartID, productID, productAttributeID, customizationID).Scan(&line).Error; err != nil { return nil, err } if line.CartID == 0 { return nil, nil } return &line, nil } func (s *Service) insertProductLine(tx *gorm.DB, cart *cartContext, productID, productAttributeID, customizationID, addressDeliveryID, quantity int64) error { available, err := s.tableColumns(tx, s.prefix+"cart_product") if err != nil { return err } now := time.Now().UTC().Format("2006-01-02 15:04:05") columns := make([]string, 0, 8) values := make([]any, 0, 8) add := func(name string, value any) { if available[name] { columns = append(columns, name) values = append(values, value) } } add("id_product", productID) add("id_product_attribute", productAttributeID) add("id_cart", cart.ID) add("id_address_delivery", addressDeliveryID) add("id_shop", cart.ShopID) add("quantity", quantity) add("date_add", now) add("id_customization", customizationID) return tx.Exec(insertQuery(s.prefix+"cart_product", columns), values...).Error } func (s *Service) updateProductLineQuantity(tx *gorm.DB, line *productLine, quantity int64) error { query := fmt.Sprintf(` UPDATE %scart_product SET quantity = ? WHERE id_cart = ? AND id_product = ? AND COALESCE(id_product_attribute, 0) = ? AND COALESCE(id_customization, 0) = ? LIMIT 1 `, s.prefix) return tx.Exec(strings.TrimSpace(query), quantity, line.CartID, line.ProductID, line.ProductAttributeID, line.CustomizationID).Error } func (s *Service) deleteProductLine(tx *gorm.DB, cartID, productID, productAttributeID, customizationID int64) error { query := fmt.Sprintf(` DELETE FROM %scart_product WHERE id_cart = ? AND id_product = ? AND COALESCE(id_product_attribute, 0) = ? AND COALESCE(id_customization, 0) = ? `, s.prefix) return tx.Exec(strings.TrimSpace(query), cartID, productID, productAttributeID, customizationID).Error } func (s *Service) touchCart(tx *gorm.DB, cartID int64) error { query := fmt.Sprintf("UPDATE %scart SET date_upd = ? WHERE id_cart = ?", s.prefix) return tx.Exec(query, time.Now().UTC().Format("2006-01-02 15:04:05"), cartID).Error } func (s *Service) summaryByIDWithDB(tx *gorm.DB, cartID int64) (*Summary, error) { var summary Summary query := fmt.Sprintf("SELECT id_cart AS id, COALESCE(SUM(quantity), 0) AS total_items FROM %scart_product WHERE id_cart = ? GROUP BY id_cart", s.prefix) result := tx.Raw(query, cartID).Scan(&summary) if result.Error != nil { return nil, result.Error } if result.RowsAffected == 0 { return &Summary{ID: cartID}, nil } return &summary, nil } func (s *Service) tableColumns(tx *gorm.DB, tableName string) (map[string]bool, error) { type row struct { ColumnName string `gorm:"column:COLUMN_NAME"` } var rows []row query := ` SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? ` if err := tx.Raw(query, tableName).Scan(&rows).Error; err != nil { return nil, err } columns := make(map[string]bool, len(rows)) for _, row := range rows { columns[row.ColumnName] = true } return columns, nil } func insertQuery(tableName string, columns []string) string { if len(columns) == 0 { return fmt.Sprintf("INSERT INTO %s () VALUES ()", tableName) } return fmt.Sprintf( "INSERT INTO %s (%s) VALUES (%s)", tableName, strings.Join(columns, ", "), placeholders(len(columns)), ) } func placeholders(n int) string { parts := make([]string, n) for i := range parts { parts[i] = "?" } return strings.Join(parts, ", ") } func formatInt64(value int64) string { if value == 0 { return "" } return strconv.FormatInt(value, 10) }