Skip to content

Pass an explicit context to pricing, tax and checkout calculations - #931

Merged
KrzysztofPajak merged 21 commits into
developfrom
refactor/business-explicit-context-pricing
Oct 16, 2026
Merged

KrzysztofPajak merged 21 commits into
developfrom
refactor/business-explicit-context-pricing

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Type: feature

Issue

PricingService, TaxService, PriceFormatter, ShoppingCartSubTotalService and OrderCalculationService read the customer, store, working currency and tax display from WorkContext / StoreContext. Prices, taxes and cart totals therefore depended on who called. Outside a request (background messages, scheduled tasks, the API module) the values were wrong or missing. Follows #930 (M1 PR 1, Catalog read paths); this branch is stacked on it.

Solution

  • New CalculationContext(Customer, Store, Currency, TaxDisplayType) record in Grand.Business.Core.Utilities.Checkout. Web builds it with _contextAccessor.CalculationContext() (Grand.Web.Common.Extensions).
  • IOrderCalculationService and IShoppingCartSubTotalService take the context as the second parameter of every calculation method. ConvertLoyaltyPointsToAmount takes the currency.
  • ITaxService: the overloads that read the tax display, customer or store are removed. Callers use the overloads with includingTax, customer, store. LoadActiveTaxProvider(customer, storeId).
  • IPricingService: GetUnitPrice(ShoppingCartItem, Product, Customer, Store, Currency, includeDiscounts), GetSubTotal(…, customer, store, currency, …) and GetProductAttributeValuePriceAdjustment(value, customer, store, currency).
  • IPriceFormatter.FormatPrice(double) is removed. Callers pass the currency.
  • The five services no longer depend on IContextAccessor. Web callers pass exactly what the services used to read. Web handlers whose request has no tax display get IContextAccessor. PlaceOrderCommandHandler and ShoppingCartValidator (next PR) build the context from their own ambient values, marked TODO.

Two deliberate fixes:

  • Order emails: the tax-rate lines are formatted in the order's currency, like every other amount in the email. Before, they used the working currency of whatever request sent the email (separate commit).
  • Attribute price adjustments: inside GetUnitPrice(product, customer, store, currency, …) they now use the given customer, store and currency instead of the ambient ones. For Web callers the values are the same. For the discount rule's spent-amount query, which prices in the primary currency, attribute adjustments are no longer converted into the working currency.

Not in this PR: ProductAttributeFormatter, which also needs the working language and the request host (follow-up PR).

Breaking changes

  • Every signature listed above changed. A plugin that calls one of these methods has to pass the context or the explicit values, and must be rebuilt.
  • ValidateMinShoppingCartSubtotalAmountCommand.Context is required: the handler throws without it. ShoppingCartTotalAmountValidatorRecord has an extra Context field.
  • Constructors changed: the five services no longer take IContextAccessor. Several Web handlers, Admin ShoppingCartController, and GetMerchandiseReturns (new Currency property) changed too.

Testing

  1. dotnet build GrandNode.slnx
  2. dotnet test GrandNode.slnx: green. Customers and Marketing fail only in the parallel run and pass when run alone.
  3. New tests:
    • CalculationContextExtensionsTests
    • GetOrderTokensCommandHandlerTests.Handle_FormatsTaxRatesInTheOrderCurrency
    • TaxServiceTests.LoadActiveTaxProvider_FallsBackToProvidersOfGivenCustomerAndStore
    • PriceServiceTests.GetSubTotal_PricesTheItemForTheGivenCustomerStoreAndCurrency
    • PriceServiceTests.GetProductAttributeValuePriceAdjustment_ConvertsToTheGivenCurrency (pins the adjustment method itself)
    • OrderCalculationServiceTests.IsFreeShipping_UsesTheCustomerOfTheContext
    • OrderCalculationServiceTests.GetShoppingCartSubTotal_PricesItemsForTheContextCustomerStoreAndCurrency
  4. Existing tests changed only in arguments and constructor wiring.

Not done: a manual pass through cart and checkout.

support added 12 commits October 16, 2026 16:00
The handlers cache the ACL-filtered result under keys shared by many customers. A query without a customer would authorize nothing and cache the empty list for all of them. Also pins DeleteCategory detaching its children and restores a negative assertion to any customer and store.
Every other amount in the order email is formatted in the currency the order was placed in; the tax rates used whatever currency the request that sent the email was working in, which for background sending is not the customer's.
support added 9 commits October 16, 2026 16:38
…stomer for hidden records

GetPersonalizedProductsQuery and GetSuggestedProductsQuery read the customer id and tags from Customer instead of separate fields that could disagree with it. The product mapping and child category lookups no longer read the customer or store when showHidden is set, so admin callers pass null. Methods that authorize through IAclService throw on a missing customer instead of returning nothing.
The admin cart and wishlist details, the customer edit page's cart tab and
the attribute price adjustments priced another customer's items with the
logged-in admin's groups, discounts and tax. They now use the customer
whose cart it is and the store the item was added in.
ConvertLoyaltyPointsToAmount returns an amount in the primary store
currency, but rounded it in the working currency, so a zero-decimal
working currency rounded 4.95 USD to 5 before conversion. The currency
parameter added for that rounding is dropped again; the amount is rounded
in the primary store currency.
The cart, mini cart, order totals and payment method handlers priced their
lines from the request but their totals from the ambient context. They now
build one context from the request fields, which no longer needs the
context accessor. GetPaymentMethod carries the tax display type like the
other cart requests.

ShoppingCartTotalAmountValidatorRecord and
ValidateMinShoppingCartSubtotalAmountCommand carried a customer and a
currency next to a context holding the same; they now read both from the
context.
The catalog read services took a customer, a store id and a showHidden flag whose meaning depended on each other: the customer had to be null exactly when hidden records were asked for. CatalogAccess.For(customer, storeId) and CatalogAccess.IncludingHidden(storeId) make that choice one argument. The store limitation now applies whenever a store id is set, so the back office passes IncludingHidden() where it ignored the store before. The storefront builds its access from the request with IContextAccessor.CatalogAccess().
…xt-catalog' into refactor/business-explicit-context-pricing

# Conflicts:
#	src/Web/Grand.Web/Features/Handlers/Products/GetProductDetailsPageHandler.cs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant