Markets
| Feature | CrestApps.OrchardCore.Markets (Markets) |
| Category | Commerce |
| Depends on | Commerce, Products (CrestApps.OrchardCore.Products) |
| Seams it fills | IShopperCurrency and IProductSaleRestriction (in CrestApps.OrchardCore.Products.Core), ICheckoutTaxProfileHandler (Checkout) |
The Markets feature sells to several countries on their own terms. A Market is a set of countries with a currency, an exchange rate from the store's currency, a price rounding rule, a culture and whether its prices include tax. The shopper's market decides the currency that product cards, product pages, the cart, the checkout, shipping and the catalog API price in. Admin screens are described in the User Manual.
The store's currency throughout is CheckoutSettings.Currency (Settings > Commerce > Checkout). Rates are relative to it.
Model
Markets live in the Markets YesSql collection, indexed by MarketIndex (Name, Currency, IsEnabled), through IMarketStore (an ICatalog<Market> with PageAsync).
Market | |
|---|---|
Name | Shown in the picker. |
Countries | Two-letter codes, upper case. The admin refuses a code another market already has, so a country resolves to one market. Covers(country) tests one. |
Currency | Three-letter code, upper case. |
ExchangeRate | How much of Currency one unit of the store's currency is worth. Must be above zero. |
Rounding | MarketPriceRounding: None, WholeUnits, EndIn99, EndIn95. |
Culture | A culture from ILocalizationService.GetSupportedCulturesAsync(), or null. |
PricesIncludeTax | Makes the checkout's taxes inclusive (see Tax). |
IsEnabled | Off markets are never resolved or offered. |
ShopperMarketService.GetMarketsAsync() returns the markets that are on, have a currency and a rate above zero, by name. Markets have no recipe or deployment step.
MarketPricing.Convert(amount, fromRate, toRate) is amount / fromRate * toRate. MarketPricing.Round(amount, currency, rounding) rounds up: WholeUnits to the next whole unit, EndIn99 and EndIn95 to the next price ending in .99 or .95 (to the next whole unit for a currency with no minor units, such as JPY), None to the currency's scale (CurrencyScale).
The shopper's currency seam
Products.Core defines the seam every pricing caller uses:
public interface IShopperCurrency
{
// The currency the current shopper buys in, or null when each product sells in its own.
ValueTask<string> GetCurrencyAsync(CancellationToken cancellationToken = default);
// The amount in fromCurrency (the store's when null) as the shopper pays it in toCurrency.
// isPrice: true rounds by the market's rule; false only to the currency's smallest unit.
// Returns null when it can't be converted.
ValueTask<decimal?> ConvertAsync(decimal amount, string fromCurrency, string toCurrency, bool isPrice = true, CancellationToken cancellationToken = default);
}
ProductOwnShopperCurrencyis the default (TryAddScopedin the Products startup): no shopper currency, and it converts nothing (an amount converts only to its own currency).ShopperMarketServicereplaces it when Markets is on.GetCurrencyAsyncis the current market's currency.ConvertAsyncconverts through the store's currency: the store's rate is 1, a market currency's rate is the shopper's market's when it's in that currency, else the first market in it. A currency that is neither the store's nor a market's can't be converted (null).ShopperCurrencyExtensions.ToShopperAsync(amount, currency, isPrice)returns(Amount, Currency)as the shopper sees it: converted when they buy in another currency, unchanged when they don't or it can't be converted. Use it for amounts that are only shown.
Register your own IShopperCurrency to price from another source, such as a rates service; consumers don't change.
Resolving the shopper's market
ShopperMarketService.GetCurrentAsync() resolves once per request:
- None in the admin (
AdminAttribute.IsApplied), or without anHttpContext, or when no market is on. - The
marketquery value, then theX-Marketheader: a market id, or a two-letter country code that a market covers (ShopperMarketService.Find). - For requests outside
/apionly: theorchard-marketcookie (a market id), then the market ofCultureInfo.CurrentCulture's region (FindByCulture:de-DEisDE). Neutral cultures resolve none.
So an API client is never placed in a market by a browser cookie or its language; it names its market on each request. See API clients.
The market picker
MarketNavbarDisplayDriver adds the MarketPicker shape (MarketPickerViewModel: Markets, Current, StoreCurrency, ReturnUrl) to the front-end Navbar at Detail / Content:5, before the cart link, when a market is on. A theme restyles it by overriding MarketPicker.cshtml (or a Liquid template), or hides it with an empty one.
The picker posts marketId and returnUrl to POST /market (MarketController.Choose, antiforgery-validated). It sets orchard-market to the market id (one year, HttpOnly, SameSite=Lax, IsEssential, Secure on HTTPS, path the tenant's PathBase), and, when the market has a supported Culture, Orchard Core's culture cookie (CookieRequestCultureProvider.DefaultCookieName). An empty or unknown marketId deletes the market cookie ("Everywhere else") and leaves the culture alone. It redirects to returnUrl when local, else to the home page.
Prices
DefaultPriceResolver takes IShopperCurrency (a second constructor; the old one still works without conversion). When ProductSnapshotContext.Currency differs from the price's currency:
ProductPricePartprices. A price the product lists in the requested currency wins: the one with the sameKind,IntervalandBillingDurationthat's on offer, preferring the sameName, then the default. Otherwise a one-time price is converted withisPrice: true. A recurring price, or one withAllowCustomAmount, is never converted: the resolver returnsnull(logged), so it can't be charged in the wrong currency.- A single
ProductPartprice is converted, then volume and role discounts come off the converted price.
Where prices are shown:
| Where | What changes |
|---|---|
Product cards (ProductListingService.BuildPriceShapeAsync) | The indexed price and compare-at price go through ToShopperAsync. The index, and so listing sorts and price-range filters, stay in the products' own currency. |
Product page (AddToCartDisplayDriver) | One-time prices listed in the shopper's currency replace the others; failing those, each is converted. Variant prices are converted as prices; add-on prices with isPrice: false. What's shown is only for display: the line is priced again when added. |
Cart (CartService) | See Carts. |
Catalog API (CatalogApiProductReader) | price comes from IPriceResolver with the shopper's currency, falling back to the converted indexed price; compareAtPrice, variant prices and add-on prices are converted. prices lists each ProductPricePart price as the product lists it, in its own currency. |
Carts
CartService takes IShopperCurrency and the IProductSaleRestrictions through a longer constructor.
- The cart's currency is the shopper's currency, else the currency its lines were added in.
- When the shopper's currency differs from
Cart.Currency(they changed market),PriceAsyncprices every line in the new currency and setsCart.Currency.CartLinePricing.PriceChangedisfalsefor that pricing, and adding or updating a line first rebases each line'sAddedUnitPriceto its price in the new currency, so a currency change never reads as a price change. - Add-on surcharges are converted from the product's currency with
isPrice: false. - A line that can't be priced in the cart's currency, but can in its own, gets the problem Mug is priced in GBP, and your cart is in EUR., which stops the checkout.
Not sold in a market
Products.Core defines:
public interface IProductSaleRestriction
{
// Why the product can't be sold to the current shopper, or null when it can.
ValueTask<string> GetRestrictionAsync(ContentItem product, CancellationToken cancellationToken = default);
}
ProductSaleRestrictionExtensions.GetRestrictionAsync(restrictions, product) returns the first reason any registered restriction gives. It is asked by:
AddToCartDisplayDriver, which renders theAddToCartUnavailableshape (AddToCartUnavailableViewModel.Reason) instead of the add-to-cart form;CartService, which refuses to add the product and gives a line the problem Mug: Not sold in Germany., so the checkout can't go on; the Cart API answers409with the codenot-available;- the catalog API, which returns
isPurchasable: false.
Markets registers MarketSaleRestriction. The product editor's Not sold in list (ProductMarketsDisplayDriver, on ProductPart at Content:22) keeps the ids of the markets ticked in ProductMarketsPart.NotSoldIn, stored on the content item beside ProductPart (it isn't added to the content type). A shopper whose current market is listed gets Not sold in Germany., with the market's name.
The product is still listed and found by search: leaving it out of listings would need a filterable field in every search backend's index.
Add your own IProductSaleRestriction for other rules, such as an age check or a licence; the three places above honour it.
Shipping
DefaultShippingRateService takes IShopperCurrency (a second constructor). When the shopper buys in a market's currency and ShippingRateRequest.Currency is that currency:
- The zone's enabled methods whose
Currencyis the market's are quoted as they are. - When those give no rate, every other method (a method with no currency is in the store's) is quoted and its amount, after class surcharges, converted with
isPrice: false. A rate that can't be converted is dropped.ShippingRate.WithAmount(amount, currency)carries the new currency.
Outside a market, methods are matched as before: a method with no currency is offered for any order currency.
Rate providers still compare amounts with the request as it is: the Free provider's MinimumSubtotal and a Table rate by subtotal are compared with the subtotal in the order's currency, unconverted. A market that needs those thresholds gets methods in its own currency.
Tax
MarketCheckoutTaxProfileHandler (ICheckoutTaxProfileHandler) sets CheckoutTaxProfile.PriceType to TaxPriceType.Inclusive when the checkout is in a market whose PricesIncludeTax is on: the enabled market in the session's currency that covers profile.Destination.Country, or else the shopper's current market when the session is in its currency. A profile already inclusive is left alone. The tax rules then extract tax from the prices; see Taxation.
TaxJar handles only prices that exclude tax, so a checkout in a market with prices that include tax is always taxed by the store's own rules.
Cultures and orders
The market's Culture switches the storefront when the market is picked. Orders keep the culture the buyer shopped in (Order.Culture), and their emails are written in it; see Carts and Orders. Translated products are Orchard Core content localization: each translation is its own content item, listed in its culture, with its own price and stock. Selling one product across its translations with shared stock and price is not built.
API clients
An app names its market on every request, the catalog, the cart and the checkout calls alike, with either:
- the
marketquery value, such asGET products?market=DE, or - the
X-Marketheader, such asX-Market: DE.
The value is a market id or a two-letter country code a market covers. Without it, a request under /api is priced in the products' own currencies. See Commerce API.
Admin and permissions
| Screen | Permission | Default roles |
|---|---|---|
Commerce > Markets (MarketsAdminController, admin route markets): the list on Orchard Core's AdminList, and the editor | ManageMarkets (Manage markets) | Administrator |
| Not sold in on a product's editor | Editing the product |
Related modules
- Products:
IPriceResolverand the listing. - Carts and Orders: carts, orders and their culture.
- Shipping: rates.
- Taxation: inclusive prices, VAT rules and the one-stop-shop report.