From ef41dbbc52df530129e2376bf84343bf621dbeda Mon Sep 17 00:00:00 2001 From: support Date: Sat, 17 Oct 2026 06:10:52 +0200 Subject: [PATCH 1/2] Keep the account's checkout attributes when merging a guest cart at login MigrateShoppingCart always wrote the guest's checkout attributes onto the account, even when the guest had none, so logging in from a fresh browser cleared a saved choice such as gift wrapping. The guest's attributes now move only when the guest has some and the account has none in that store. --- .../Services/Orders/ShoppingCartService.cs | 13 ++-- .../Orders/ShoppingCartServiceTests.cs | 70 ++++++++++++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartService.cs index 47d4e6349..7e7e5b31a 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartService.cs @@ -392,7 +392,9 @@ public virtual async Task DeleteShoppingCartItem(Customer customer, ShoppingCart /// /// From customer /// To customer - /// Store whose selected checkout attributes move along + /// + /// Store whose selected checkout attributes move along, when the target customer has none in that store + /// /// /// A value indicating whether to coupon codes (discount and gift voucher) should be also /// re-applied @@ -440,10 +442,13 @@ await AddToCart(toCustomer, sci.ProductId, sci.ShoppingCartTypeId, sci.StoreId, await _customerService.UpdateUserField(toCustomer, SystemCustomerFieldNames.GiftVoucherCoupons, resultGift); } - //move selected checkout attributes + //move selected checkout attributes, but only into an account that has none of its own yet, + //so logging in from a fresh browser does not clear a choice saved on the account var checkoutAttributes = fromCustomer.GetUserFieldFromEntity>(SystemCustomerFieldNames.CheckoutAttributes, storeId); - await _customerService.UpdateUserField(toCustomer, SystemCustomerFieldNames.CheckoutAttributes, checkoutAttributes, - storeId); + var accountCheckoutAttributes = toCustomer.GetUserFieldFromEntity>(SystemCustomerFieldNames.CheckoutAttributes, storeId); + if (checkoutAttributes is { Count: > 0 } && accountCheckoutAttributes is not { Count: > 0 }) + await _customerService.UpdateUserField(toCustomer, SystemCustomerFieldNames.CheckoutAttributes, checkoutAttributes, + storeId); } #endregion diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartServiceTests.cs index 9c883d8dc..7cd24bd14 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartServiceTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartServiceTests.cs @@ -11,9 +11,11 @@ using Grand.Domain.Stores; using Grand.Domain.Tax; using Grand.Infrastructure; +using Grand.Infrastructure.TypeConverters; using Grand.Mediator; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using System.Text.Json; namespace Grand.Business.Checkout.Tests.Services.Orders; @@ -30,6 +32,9 @@ public class ShoppingCartServiceTests [TestInitialize] public void Init() { + //user fields are stored as strings; the converters read them back as typed values + new WebTypeConverter().Register(); + _productServiceMock = new Mock(); _shoppingCartSettings = new ShoppingCartSettings(); _customerServiceMock = new Mock(); @@ -283,6 +288,7 @@ public async Task MigrateShoppingCart_MovesCheckoutAttributesOfTheGivenStore() { //Arrange var fromCustomer = new Customer(); + AddCheckoutAttributes(fromCustomer, "store-b", "gift-wrapping", "yes"); var toCustomer = new Customer(); //Act @@ -290,6 +296,68 @@ public async Task MigrateShoppingCart_MovesCheckoutAttributesOfTheGivenStore() //Assert _customerServiceMock.Verify(x => x.UpdateUserField(toCustomer, SystemCustomerFieldNames.CheckoutAttributes, - It.IsAny>(), "store-b"), Times.Once); + It.Is>(a => a.Count == 1 && a[0].Key == "gift-wrapping" && a[0].Value == "yes"), + "store-b"), Times.Once); + } + + [TestMethod] + public async Task MigrateShoppingCart_GuestWithoutCheckoutAttributes_KeepsTheAccountAttributes() + { + //Arrange + var guest = new Customer(); + var customer = new Customer(); + AddCheckoutAttributes(customer, "store", "gift-wrapping", "yes"); + + //Act + await _shoppingCartService.MigrateShoppingCart(guest, customer, "store", false); + + //Assert + _customerServiceMock.Verify(x => x.UpdateUserField(It.IsAny(), + SystemCustomerFieldNames.CheckoutAttributes, It.IsAny>(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task MigrateShoppingCart_AccountWithCheckoutAttributes_KeepsThemOverTheGuestAttributes() + { + //Arrange + var guest = new Customer(); + AddCheckoutAttributes(guest, "store", "gift-wrapping", "no"); + var customer = new Customer(); + AddCheckoutAttributes(customer, "store", "gift-wrapping", "yes"); + + //Act + await _shoppingCartService.MigrateShoppingCart(guest, customer, "store", false); + + //Assert + _customerServiceMock.Verify(x => x.UpdateUserField(It.IsAny(), + SystemCustomerFieldNames.CheckoutAttributes, It.IsAny>(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task MigrateShoppingCart_AccountWithAttributesInAnotherStore_TakesTheGuestAttributes() + { + //Arrange + var guest = new Customer(); + AddCheckoutAttributes(guest, "store-b", "gift-wrapping", "yes"); + var customer = new Customer(); + AddCheckoutAttributes(customer, "store-a", "gift-wrapping", "no"); + + //Act + await _shoppingCartService.MigrateShoppingCart(guest, customer, "store-b", false); + + //Assert + _customerServiceMock.Verify(x => x.UpdateUserField(customer, SystemCustomerFieldNames.CheckoutAttributes, + It.Is>(a => a.Count == 1 && a[0].Value == "yes"), "store-b"), Times.Once); + } + + private static void AddCheckoutAttributes(Customer customer, string storeId, string key, string value) + { + customer.UserFields.Add(new UserField { + Key = SystemCustomerFieldNames.CheckoutAttributes, + Value = JsonSerializer.Serialize(new List { new() { Key = key, Value = value } }), + StoreId = storeId + }); } } \ No newline at end of file From 94a9735ea4f19231fc5031c6252f33c02a9fd23d Mon Sep 17 00:00:00 2001 From: support Date: Sat, 17 Oct 2026 06:23:42 +0200 Subject: [PATCH 2/2] Let the guest's checkout attributes win when it has any The account's stored checkout attributes are only cleared when an order is placed, so they can come from a cart abandoned long ago or point at attributes that no longer apply. Keeping them over a selection the customer just made as a guest applied stale choices to the order. The guest's selection now always moves when it has one; the account keeps its own only when the guest has none, which is what the login-from-a-fresh-browser case needs. The tests register the type converters once per class and build stored values through the shipped converter. --- .../Services/Orders/ShoppingCartService.cs | 10 +++++----- .../Orders/ShoppingCartServiceTests.cs | 20 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartService.cs index 7e7e5b31a..ba0a992d3 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartService.cs @@ -393,7 +393,7 @@ public virtual async Task DeleteShoppingCartItem(Customer customer, ShoppingCart /// From customer /// To customer /// - /// Store whose selected checkout attributes move along, when the target customer has none in that store + /// Store whose selected checkout attributes move along, when the source customer has selected any there /// /// /// A value indicating whether to coupon codes (discount and gift voucher) should be also @@ -442,11 +442,11 @@ await AddToCart(toCustomer, sci.ProductId, sci.ShoppingCartTypeId, sci.StoreId, await _customerService.UpdateUserField(toCustomer, SystemCustomerFieldNames.GiftVoucherCoupons, resultGift); } - //move selected checkout attributes, but only into an account that has none of its own yet, - //so logging in from a fresh browser does not clear a choice saved on the account + //move selected checkout attributes; the source customer's selection is the most recent one, + //but when it has none, the target keeps its own, so logging in from a fresh browser + //does not clear a choice saved on the account var checkoutAttributes = fromCustomer.GetUserFieldFromEntity>(SystemCustomerFieldNames.CheckoutAttributes, storeId); - var accountCheckoutAttributes = toCustomer.GetUserFieldFromEntity>(SystemCustomerFieldNames.CheckoutAttributes, storeId); - if (checkoutAttributes is { Count: > 0 } && accountCheckoutAttributes is not { Count: > 0 }) + if (checkoutAttributes is { Count: > 0 }) await _customerService.UpdateUserField(toCustomer, SystemCustomerFieldNames.CheckoutAttributes, checkoutAttributes, storeId); } diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartServiceTests.cs index 7cd24bd14..7dabe6e62 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartServiceTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartServiceTests.cs @@ -15,7 +15,7 @@ using Grand.Mediator; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; -using System.Text.Json; +using System.ComponentModel; namespace Grand.Business.Checkout.Tests.Services.Orders; @@ -29,12 +29,16 @@ public class ShoppingCartServiceTests private ShoppingCartSettings _shoppingCartSettings; private Mock _shoppingCartValidatorMock; - [TestInitialize] - public void Init() + [ClassInitialize] + public static void InitClass(TestContext context) { //user fields are stored as strings; the converters read them back as typed values new WebTypeConverter().Register(); + } + [TestInitialize] + public void Init() + { _productServiceMock = new Mock(); _shoppingCartSettings = new ShoppingCartSettings(); _customerServiceMock = new Mock(); @@ -318,7 +322,7 @@ public async Task MigrateShoppingCart_GuestWithoutCheckoutAttributes_KeepsTheAcc } [TestMethod] - public async Task MigrateShoppingCart_AccountWithCheckoutAttributes_KeepsThemOverTheGuestAttributes() + public async Task MigrateShoppingCart_GuestWithCheckoutAttributes_ReplacesTheAccountAttributes() { //Arrange var guest = new Customer(); @@ -330,9 +334,8 @@ public async Task MigrateShoppingCart_AccountWithCheckoutAttributes_KeepsThemOve await _shoppingCartService.MigrateShoppingCart(guest, customer, "store", false); //Assert - _customerServiceMock.Verify(x => x.UpdateUserField(It.IsAny(), - SystemCustomerFieldNames.CheckoutAttributes, It.IsAny>(), It.IsAny()), - Times.Never); + _customerServiceMock.Verify(x => x.UpdateUserField(customer, SystemCustomerFieldNames.CheckoutAttributes, + It.Is>(a => a.Count == 1 && a[0].Value == "no"), "store"), Times.Once); } [TestMethod] @@ -356,7 +359,8 @@ private static void AddCheckoutAttributes(Customer customer, string storeId, str { customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.CheckoutAttributes, - Value = JsonSerializer.Serialize(new List { new() { Key = key, Value = value } }), + Value = TypeDescriptor.GetConverter(typeof(List)) + .ConvertToInvariantString(new List { new() { Key = key, Value = value } }), StoreId = storeId }); }