Store-Specific Pricing: From Variant Workaround to Price Engine Customization
Two working designs for per-point-of-service prices in SAP Commerce: the no-code variant product approach, and a full price engine customization spanning composable storefront context, OCC filters, PDTRow relations, and Solr indexing.
A price in SAP Commerce is resolved along four dimensions: product, currency, unit, and user group. The moment the business asks for a fifth dimension, "this camera costs 254 in the Walldorf store and 274 in Frankfurt", you are outside what PriceRow models natively, and you have a design decision to make. This guide covers both viable answers: a variant-product convention that needs no engine changes, and a real price engine customization that adds point of service (PoS) as a first-class pricing dimension across the storefront, OCC, the service layer, and Solr.
Choosing between them is a scale question. A handful of stores with occasional price differences: variants work. Hundreds of stores, frequent price maintenance, or PoS pricing combined with other dimensions: customize the engine, because the variant matrix explodes combinatorially.
How Price Flows Out of the Box#
Before touching anything, know the three paths a price travels, because each needs the PoS dimension injected separately.
Product detail (OCC). The PDP price comes through ProductsController:
curl -k "https://localhost:9002/occ/v2/electronics/products/301233?fields=price&curr=USD"
# <price><currencyIso>USD</currencyIso><value>21.56</value></price>
The curr parameter is captured by SessionCurrencyFilter, which places the currency in a request-scoped session; the price populator eventually reaches DefaultSLFindPriceStrategy.getPriceInformation(), where DefaultPDTCriteriaFactory turns session state into FlexibleSearch criteria. The call chain, compressed:
ProductsController.getProduct()
SessionCurrencyFilter // 'curr' param -> session currency
DefaultProductFacade.getProductForCodeAndOptions()
productPricePopulator
DefaultCommercePriceService.getFromPriceForProduct()
DefaultPriceService.getPriceInformationsForProduct()
DefaultSLFindPriceStrategy.getPriceInformation()
DefaultPDTCriteriaFactory.priceInfoCriteriaFromBaseCriteria()
Product listing (Solr). PLP prices are read from the index, one field per currency (priceValue_usd_double), filled at index time by productPriceValueProvider, which iterates the index config's currencies, sets each into the session, and asks the price service.
Cart calculation. Every entry change recalculates through DefaultCalculationService, landing in DefaultSLFindPriceStrategy.findBasePrice(), whose criteria come from PDTCriteriaFactory.priceValueCriteriaFromOrderEntry(). This is the path that decides what the customer actually pays.
Whatever design you pick must answer: where does the PoS context live in each of these three paths?
Design 1: Variant Products per Store#
The convention: the base product carries all content; one VariantProduct per store carries only the store's PriceRow, with variant.code = baseCode + posCode:
| BaseProduct | Variant (Shop A) | Price | Variant (Shop B) | Price |
|---|---|---|---|---|
| SONYCAMERA | SONYCAMERA-SHOPA | 31 USD | SONYCAMERA-SHOPB | 33 USD |
Declare a variant type that knows its store:
<itemtype code="ShopVariantProduct" extends="VariantProduct"
autocreate="true" generate="true">
<description>Variant carrying the point-of-service assignment</description>
<attributes>
<attribute qualifier="pos" type="PointOfService">
<persistence type="property"/>
</attribute>
</attributes>
</itemtype>
And wire the data:
$productCatalog = electronicsProductCatalog
$catalogVersion = catalogversion(catalog(id[default=$productCatalog]), version[default='Staged'])[unique=true]
$baseProduct = baseProduct(code, $catalogVersion)
INSERT_UPDATE Product; code[unique=true]; variantType(code); $catalogVersion
; 553637 ; ShopVariantProduct
INSERT_UPDATE ShopVariantProduct; code[unique=true]; $baseProduct; unit(code); pos(name); approvalstatus(code)[default='approved']; $catalogVersion
; 553637_shopa ; 553637 ; pieces ; walldorf-store ;
; 553637_shopb ; 553637 ; pieces ; frankfurt-store ;
INSERT_UPDATE PriceRow; productId[unique=true]; unit(code[unique=true, default=pieces]); currency(isocode)[unique=true]; price; minqtd; unitFactor; net
; 553637_shopa ; pieces ; USD ; 254 ; 1 ; 1 ; false
; 553637_shopb ; pieces ; USD ; 274 ; 1 ; 1 ; false
What makes this attractive: zero engine changes. Solr's default variant handling indexes the variants; the OCC converters already assemble base-product content for a variant request; the storefront simply navigates to /product/553637_shopa/... and everything (price, add to cart, checkout) works because as far as the platform knows, the store's offer is just a product.
The trade-offs are equally concrete. The matrix grows as products times stores; ten thousand products across fifty stores is half a million variants to import, index, and synchronize. The URL now encodes the store, which is either an SEO feature or an SEO liability depending on your strategy. And the design maxes out at one extra dimension: needing PoS pricing plus, say, customer-tier pricing per store leaves you nowhere to go.
Variants earn their keep when stores customize more than price (local assortments, local descriptions), because then the variant is modeling a real business object, not just carrying a number.
Design 2: PoS as a Price Engine Dimension#
The scalable design relates PriceRow directly to PointOfService:
| Product | PoS | Price |
|---|---|---|
| SONYCAMERA | SHOPA | 31 USD |
| SONYCAMERA | SHOPB | 33 USD |
One PriceRow per store per product, no variant explosion, and the fallback semantics you want (no PoS row means base price applies) implemented where pricing decisions belong. The cost: coordinated changes in five places.
1. Data model#
<relation code="Pos2PDTRowRelation" generate="true" localized="false" autocreate="true">
<sourceElement qualifier="pos" type="PointOfService" cardinality="one"/>
<targetElement qualifier="posPDTRows" type="PDTRow" cardinality="many"/>
</relation>
<!-- a cart/order is priced for exactly one PoS -->
<itemtype code="AbstractOrder" autocreate="false" generate="false">
<attributes>
<attribute qualifier="pointOfService" type="PointOfService">
<persistence type="property"/>
</attribute>
</attributes>
</itemtype>
Relating at the PDTRow level rather than PriceRow means discounts and taxes can become store-specific later with the same machinery.
2. Storefront context (composable storefront)#
The store must be part of the site context, exactly like language and currency, and it belongs in the URL: shareable links, per-store SEO indexing, and cacheable OCC requests all depend on it.
provideConfig({
context: {
baseSite: ['electronics-spa'],
urlParameters: ['baseSite', 'shop', 'language', 'currency'],
shop: ['walldorf-store'],
currency: ['USD'],
language: ['en'],
},
})
Getting the parameter onto every OCC request means extending the site-context interceptor:
@Injectable({ providedIn: 'root' })
export class ShopSiteContextInterceptor extends SiteContextInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (request.url.includes(this.occEndpoints.getBaseUrl())) {
request = request.clone({
setParams: {
lang: this.activeLang,
curr: this.activeCurr,
shop: this.activeShop, // resolved from the URL segment / shop service
},
});
}
return next.handle(request);
}
}
registered via HTTP_INTERCEPTORS with multi: true. Complete the UX with a store selector component and a ShopService mirroring how CurrencyService works; the CMS site-context mechanism is the template to copy.
3. OCC layer#
Server-side, the shop parameter needs the same treatment curr gets: an HTTP filter that resolves it and stores it in the request-scoped session. Generate a custom OCC extension (ant extgen, template yocc), implement a SessionShopFilter, and merge it into the commercewebservices filter chain at the right position:
<bean id="sessionShopFilterV2" class="com.acme.price.filter.SessionShopFilter">
<property name="sessionService" ref="sessionService"/>
<property name="flexibleSearchService" ref="flexibleSearchService"/>
</bean>
<bean name="occShopFilterListMergeDirective"
depends-on="commerceWebServicesFilterChainListV2" parent="listMergeDirective">
<property name="add" ref="sessionShopFilterV2"/>
<property name="afterBeanNames">
<list value-type="java.lang.String">
<value>cmsPreviewTicketFilter</value>
<value>commerceWebServicesConsentFilterV2</value>
</list>
</property>
<property name="beforeBeanNames">
<list value-type="java.lang.String">
<value>cxOccPersonalizationFilter</value>
<value>cartMatchingFilter</value>
</list>
</property>
</bean>
Do not skip the cache: OCC response caching keys on site, language, and currency. A response cached without the shop in the key serves Walldorf prices to Frankfurt. Override commerceCacheKeyGenerator with a version that includes the shop:
<bean id="commerceCacheKeyGenerator"
class="com.acme.price.core.cache.ShopAwareCacheKeyGenerator"
parent="wsCacheKeyGenerator">
<property name="baseSiteService" ref="baseSiteService"/>
<property name="sessionService" ref="sessionService"/>
</bean>
4. Service layer#
The pleasant surprise of this design: the price service itself does not change. Everything happens in the query construction and criteria plumbing around DefaultSLFindPriceStrategy:
<!-- inject PoS into the criteria built from session/order state -->
<alias name="acmePdtCriteriaFactory" alias="PDTCriteriaFactory"/>
<bean id="acmePdtCriteriaFactory"
class="com.acme.price.core.pdt.AcmePDTCriteriaFactory"
parent="defaultPDTCriteriaFactory">
<property name="sessionService" ref="sessionService"/>
</bean>
<!-- extend the PDT query with the PoS join/condition -->
<alias alias="pdtRowsQueryBuilder" name="acmePDTRowsQueryBuilder"/>
<bean id="acmePDTRowsQueryBuilder" class="com.acme.price.core.pdt.AcmePDTRowsQueryBuilder"/>
<alias alias="priceQueryProvider" name="acmePriceQueryProvider"/>
<bean id="acmePriceQueryProvider" class="com.acme.price.core.pdt.AcmePriceQueryProvider">
<constructor-arg name="pdtEnumGroupsHelper" ref="pdtEnumGroupsHelper"/>
<constructor-arg name="pdtRowsQueryBuilder" ref="pdtRowsQueryBuilder"/>
</bean>
<!-- fallback: prefer the PoS-matched row, else the base row -->
<alias alias="priceRowInfoMatchComparatorProvider" name="acmePriceRowComparatorProvider"/>
<bean id="acmePriceRowComparatorProvider"
class="com.acme.price.core.pdt.AcmePriceRowInfoMatchComparatorProvider"/>
The comparator is where the business rule lives: a PriceRow bound to the session's PoS outranks the unbound base row, and with no PoS in context the base row wins as before. Cart calculation picks the dimension up through the same criteria factory via the order's pointOfService attribute, which your add-to-cart flow must set.
5. Solr#
Store prices need index fields per currency and PoS, produced by a customized productPriceValueProvider that iterates configured stores the way the default iterates currencies:
curl -k -u "solrserver:pwd" \
"https://localhost:8983/solr/master_electronics_Product_default/select?q=code_string:553637" | grep priceValue
# "priceValue_usd_double":264.69,
# "priceValue_usd_walldorf-store_double":254,
# "priceValue_usd_frankfurt-store_double":274,
The scaling warning is not optional: fields multiply as currencies times stores, and thousands of fields per document degrade Solr measurably. Index a store-specific field only where the store price differs from base; the storefront falls back to the base field when the store field is absent. That single rule keeps most real catalogs (where store exceptions are sparse) at a sane index width. If you need to control which stores get indexed per index configuration, model an index-config-to-PoS relation alongside the existing currency list.
Data and verification#
INSERT_UPDATE PriceRow; productId[unique=true]; pos(name)[unique=true]; unit(code[unique=true, default=pieces]); currency(isocode)[unique=true]; price; minqtd; unitFactor; net
; 553637 ; ; pieces ; USD ; 264.69 ; 1 ; 1 ; false # base price
; 553637 ; walldorf-store ; pieces ; USD ; 254 ; 1 ; 1 ; false
; 553637 ; frankfurt-store ; pieces ; USD ; 274 ; 1 ; 1 ; false
End-to-end proof is three curls:
# no shop -> base price
curl -k ".../occ/v2/electronics/products/553637?fields=price&curr=USD"
# -> 264.69
curl -k ".../occ/v2/electronics/products/553637?fields=price&curr=USD&shop=walldorf-store"
# -> 254.0
curl -k ".../occ/v2/electronics/products/553637?fields=price&curr=USD&shop=frankfurt-store"
# -> 274.0
Then verify the paths that curls do not cover: add to cart under each shop context (the calculated entry total must match the shop price), shop switching mid-session (cart must recalculate), and cached PLP responses under alternating shop parameters (no bleed-through means your cache key generator works).
Choosing and Estimating#
| Factor | Variants | Engine customization |
|---|---|---|
| Development effort | Days | Weeks |
| Data volume | Products x stores | One row per actual price difference |
| Fits alongside other price dimensions | Poorly | Natively |
| Store-specific content beyond price | Natural fit | Needs separate solution |
| Sync and index cost | Grows with matrix | Grows with real differences only |
One final architecture note: if prices are mastered externally (ERP, a pricing service) and stores number in the hundreds, look hard at whether commerce should resolve store prices at all, or whether the integration should deliver pre-resolved prices per store context. The engine customization above is the right pattern when commerce owns pricing; it is expensive scaffolding when commerce is merely displaying someone else's answer.