Wednesday, 20 November 2024

Considerations for an OrderCloud (commerce) project

Unfortunately this year I didn't end up making it to Symposium, which was bitterly disappointing as it would have been fantastic to also attend the MVP Summit. Though I had prepared a presentation which was accepted by the Symposium team, it was designed to be (and required to be) co-presented with a client who ended up withdrawing. So while I didn't get to present (and I may hopefully yet at a SUG in the new year), I figured I would share the content in a blog post.

Undertaking any sort of enterprise site build is generally a mammoth effort, but when you factor commerce into the mix it can quickly become exponentially more complex, and risky - since there are now direct cost implications. Fortunately I'm here to report that if you keep things reasonably simple, and understand the tools you're working with - in our case, mainly Sitecore 10.2 (headless) and OrderCloud - it's all very much achievable. In our case, the build (for release 1) was around 8 months, which I personally feel is on the quick side.

Preparation

Documentation & Architecture

The first (and in my opinion most important) thing in any project is the preparation, and that means understanding your requirements in detail, and documenting them somewhere easily available to the full team (and any other partner teams who might end up working on the project). Make sure you have very good BAs involved, and I would highly recommend a wiki, or at least something which allows users to comment so that they can ask for clarification on any points that they don't understand, and the page can be updated with further information as it becomes available.  The documentation of business requirements should be as thorough as possible from the start, so that further planning and build (see sections below) can be based on it and prevent any (potentially major, time-consuming) changes if anything is missed.  It's very easy to say "we need the ability for users to apply coupon codes" without realising the deep deep rabbit hole promotions can very quickly become, for example.

Another key first step in any project is to understand (and potentially select) the tools with which you will be working; make sure they provide the capabilities necessitated by your business requirements (documented above); and put together a solution architecture so that everyone knows and understands how the information is flowing.  In our case, like most commerce builds, they key tools involved were:

  • Identity provider
  • PIM (source of truth for product info)
  • CMS (or DXP if you want to be fancy)
  • Middleware (optional)
  • Payment gateway
  • Order management
  • Downstream systems such as invoicing or analytics

In our case it was a bit of a rebuild, so the identity provider, PIM, and downstream systems were already in place; Sitecore 10.2 was already in use, so would also be used to enhance the product info; OrderCloud was selected for order management; and the client chose to go with a BFF (backend-for-frontend) pattern (using .NET 8) to support the frontend and extend the OrderCloud APIs.

It's worth pausing a second to reflect on the fact that there are both positives and negatives to having a BFF, particularly when you're using Next.JS for your "head" (which also provides server-side functionality). Personally (obviously heavily project-dependent) I think you can get away without one, but it can certainly come in handy.

  • Positive: It can be re-used between frontends, such as websites and mobile apps
  • Positive: It can be useful to have an extra layer to extend the OC APIs, merge product data with data from Sitecore, and handle exceptions
  • Positive: It can be used to house webhook endpoints called by OC (eg. on order submission)
  • Positive: It can be separately, and more rigorously, secured
  • Negative: It requires additional server(s) and infrastructure management
  • Negative: It's an extra layer, and one more thing to think about, when you already have "backend" Next.JS API endpoints available

OrderCloud

Another important consideration during preparation is around authentication, considering that OrderCloud provides SSO via OIDC.  In our case we were already using next-auth and another identity provider for other client sites, and SSO between the client sites was a requirement, so rather than rewrite all the client sites to use OC as the identity provider we opted to use OrderCloud impersonation in the BFF layer. It's great that OrderCloud provides flexibility by providing both these options.

Once you've documented your business requirements you will hopefully understand all the attributes and metadata that are associated with your products, and may recognise that you have a parent-child relationship between products. I have documented extensively the options available in OrderCloud around variants and parent-child products so I won't touch on it again here, however I'd highly recommend a read if you haven't already looked into it. Once you have a solid understand your product data I'd highly recommend creating a spreadsheet outlining: whether the data is read-only or can be modified by authors, what the expected values and lengths are, and whether the data is going to be stored in OrderCloud (ie. as Product xp data) or a separate system such as Sitecore. As you may be aware, Product xp has a max limit of 8000 characters, which may seem like a lot but can quickly and easily be used up and is not recommended for things such as rich text. My recommendation would be to use it only for data on which you will be searching / sorting / filtering products, and store the rest in Sitecore where you have almost unlimited flexibility (see build tips below on a note about sync'ing).  One key thing to note here is that if you want to give users the ability to sort products based on price, you will need to store the price in product xp data.  This is probably the biggest limitation I have come across in OrderCloud, but in our case it was easily surmountable as products had quite simple pricing, plus we had the added benefit of the BFF layer to house extra logic, so we were lucky this time. 

The final aspect of OrderCloud planning I'll touch on is the webhooks / integration events available. You should certainly familiarise yourself with the Order Checkout integration event, which is used during calculations (eg. tax), validation (eg. promotions), and of course order submission, so you probably won't be able to get away without implementing this one.  The other one which we found extremely handy is product synchronisation, which is fired whenever a product is created or modified, and can be handy to calculate and populate xp values (see paragraph above). This is fired for both parent and child products, and also when a product's price schedules are modified, so it's an extremely handy tool to have in your back pocket.

Project Planning

I'm no Project Manager, but it would be remiss of me to neglect to mention project planning in the preparation phase. Ensure you have your boards / user stories / tasks in whatever tool you use, and make sure your epics and sprints are defined, or you've at least understood the main phases of the project.  As I touched on in the foreword I would highly recommend launching an MVP (whatever that means for your project) and quickly following that up with a second release to flesh out the functionality, just to reduce complexity of the initial build, and keep timelines manageable.  

Key components of a commerce site you'll want to factor in: product listing page (PLP) including search / sorting / filtering, product details page (PDP), wishlist (optional), cart, checkout, confirmation, previous order listing, and of course all your standard web pages along with banners, carousels, rich text, and the like.

Build Considerations and Learnings

I feel at this stage (at least at the time of writing) Sitecore has gone all-in on Next.JS (before you comment: yes it seems this is likely to change in the near future), so depending on when you're reading this it's likely you've selected the Next.JS route, however it's still important to understand whether you're going SSR or SSG, what your ISR interval will be (how often are your products or content changing?), where your logic will live (Next.JS APIs?), and of course where your head is going to be hosted.

Additionally, authentication can be more complex than you expect, especially if you require SSO between other sites in the same ecosystem. As mentioned above, we went with the next-auth route (I'm keen to know what other options others may have gone with, let me know), but it's important to keep your auth and OC token in sync (if you're not authenticating through OC, at least).  Make sure users are logging out of both, anon carts potentially being deleted, and of course sessions and tokens are ending at the same time.

You may have content or products which should change or show/hide depending on your visitors' region, as we did. You can, of course, take advantage of the browser geolocation API, but if you happen to be running XP (as we were) you can also take advantage of Sitecore's geo-IP lookup - just don't forget to enable geo-IP in your Sitecore license.

If you're building a commerce site one of your primary considerations will likely be getting your products sold, so SEO should be front of mind. Of course you should aim for a fantastic pagespeed score, use the usual HTML and opengraph metadata, and have a sitemap, but also consider using microdata such as Product to enhance your page markup.  If you're migrating from a previous commerce implementation, or regularly removing products, you should also factor in setting up redirects from previous product URLs to your new product URLs, or just to PLP, as Google penalises 404 responses.

Tips and Tricks

  • Get used to working with an OC token (JWT) and using impersonation - your token is your context user (with specific permissions allowed), and even in the OC Portal you are actually using impersonation and have a token
  • OrderCloud xp properties are case-sensitive - OrderCloud will happily allow you to add price and Price which may not be very obvious when you go to deserialize/read it from your code
  • OrderCloud allows you to move users between Buyers, however you can’t move users with “open” orders (you must ensure they are "completed")
  • Make sure your BAs and developers are familiar with order of operations eg. promos are applied before tax is calculated
  • If you want to read Cart data (as opposed to Order data), there is a hidden (or at least not well documented role) that you will need: UnsubmittedOrderReader
  • Get used to writing a lot of loops: OC will only let you retrieve 100 items at a time
  • Facet values returned in the metadata are based on the first 50 results
  • Try to avoid using the .NET decimal type in xp - you might try to store 0.5m but end up seeing 0.5000001 stored in the xp
  • Webhook secrets have a 50 character limit
  • There is nothing stopping you from having a negative total, so make sure you cap your promotions
  • For guest/anonymous users, you cannot add items to a cart once it has been submitted. You must get a fresh token and create a new cart.
  • Don’t forget to assign your checkout integration event to your API client(s) otherwise you might end up wondering why it's not firing
  • For product facet values: take advantage of ProductSync events to create custom xp values which correspond with the facet values you need (eg. Price Range $100-$200)
  • Create publish event handler / publish webhook in Sitecore to sync data from Sitecore to OC, so that you can take advantage of in-built OrderCloud search/filtering features (if you're not using a third-party search tool)
  • Always validate your payment in the backend. This can be a good opportunity to patch your payment with Accepted=true.
  • Whenever you add a new cart line item, or patch the cart or a cart line item, you must call the order calculate method. Before submission you must also call order validate. Whenever you add a payment you must also (again) call order validate before you submit (ensuring that your payments with Accepted=true add up to the order total).

Testing

Performance should always be a key consideration throughout any project, and hopefully also being tested throughout, but there will likely be a specific load testing phase. Like any project, scale your servers appropriately (automatically if you can) and I would recommend notifying the OC team when you are planning to conduct your load tests so that they don't send concerned emails your way.  A key thing to note is that OrderCloud implements throttling in non-production environments (and don't publish any metrics around how throttling is implemented) and we certainly hit this during our testing. 

Depending on your code, you could implement their Throttler or just aim to reduce the requests you make to OC as much as possible. An example would be instead of using a loop in your code to GET individual products by ID, perform a single GET with ?ID=id1|id2|id3. We didn't end up needing to cache data but that is certainly an option depending on your load and the frequency with which your data changes. 

Finally, use a filter over a search where possible. Instead of ?searchOn=ID&search=id1 just use ?ID=id1. This applies to pretty much any entity.

Security is also hopefully also always front of mind by all architects and developers, but likely you will have a penetration testing phase of the project. Make sure that any security tests don't make it through to OC - ie. make sure that any user fields, query strings, or anything else that might end up in API calls to OrderCloud is sanitised. Sitecore/OrderCloud has a usage policy which prohibit testing of their services, and testing of your application may inadvertently appear as though you are testing OrderCloud.

Additional security considerations:

  • Make sure you are using a secret string for your webhooks (secret) / delivery configurations (secret) / integration events (hash key) which you are validating in the backend - note that some of these fields have a 50 character limit
  • You may also need to allow OrderCloud access to your webhooks through things like CloudFlare or WAFs.  If you are using CloudFlare, you may want to also additionally validate the webhook secret using a CloudFlare worker. After sending a request via email, the OrderCloud team kindly (and quickly) agreed to implement a user-agent which we found was previously causing the requests to be blocked by default WAF rules. Thanks so much guys!
  • OrderCloud is PCI compliant (assuming you don't go doing something silly like storing credit card data in xp)

Release

The release for every project will likely be different, so I won't dive into too much detail. Assuming you have ensured you have your environment variables are up to date with things like OrderCloud marketplace ID, API keys, webhook URLs, and secrets, most of your focus will be on other systems such as Sitecore or your "head".  Like any release, you will hopefully have a release plan outlining the various systems and order in which they will be deployed, and OrderCloud will likely be towards the beginning as it likely does not have many - if any - dependencies.

One thing I would highly recommend is having a script to create or update the data for your environment(s), whether they be non-production or production.  This could be in the form of a Node or .NET script, or a simple Postman collection that you run.  Note that entities need to be created in a certain order (eg. catalogs before categories before products) and that when creating a new environment from scratch you may have a couple of manual steps, such as copying the API keys (which have IDs generated on creation) to impersonation configs.

After Launch

Of course, things don't end after launch! It's important to sanity check not only your visitor-facing content (and check for any broken links) but also monitor how things are running behind the scenes.

Make sure you keep a close eye on, at least:

  • your server metrics, 
  • your logs,
  • analytics - your visitor count, and any purchase data,
  • your pagespeed score, 
  • your SEO ranking

It's not specific to a commerce implementation, but you should always ensure that you have sufficiently detailed logging to allow you to debug issues. Make sure you're not just logging that an error occurred, but who triggered it, what they were trying to do, and any other context which you may need to associate the debug log with an email or support ticket that customers may raise. When you're dealing with a commerce site you have the

Finally, ensure you scale your servers based on your metrics (or better yet, implement auto-scaling) to ensure you're not over-spending!

Conclusion

While undertaking an enterprise commerce site build can be a mammoth and daunting task, having a solid understanding of your requirements and tools, as well as keeping things simple for first launch, can go a long way to keeping things manageable (dare I say enjoyable?).  Sitecore has been around forever at this point, its capabilities are well known and documented, and they play very well when cooperating with OrderCloud (a relatively new player by comparison). While OrderCloud has a couple of limitations (which may affect you more than it did us) it is an easy to use, flexible, and performant solution, which should allow you to get up and running quickly and painlessly. If in doubt, reach out to Sitecore and see if you can get access to an OC Solution Engineer to assist with you on your journey.

This ended up being quite the essay, and I considered breaking this into multiple blog posts, but hopefully it's easy to skim and come back to later where required to refresh your memory. Hope you enjoyed the read and got something out of it. Good luck on your commerce journey!

Wednesday, 7 August 2024

OrderCloud Promotions Gotchas

On the OrderCloud project that we're wrapping up we came across a couple of interesting little "features" of promotions which we hadn't anticipated up-front, so I thought I'd share our experience in the hopes that this helps someone else out there.

OrderCloud Promotions

OrderCloud's promotion system is pretty amazing. If you aren't familiar I would have a quick skim through:

  1. Rules-Based Promotion Expressions
  2. Introducing Line Item-Level Promotions
  3. Promotion Enhancements

(which are in the order they were released/published).

As you can see, there are multiple different ways to customise the Promotion's EligibleExpression and ValueExpression to suit your every need (at least, all the examples we required). This could include values such as:

  • Order properties, eg. order.Total
  • Line item properties, eg. item.ProductID
  • Visitor properties, eg. order.FromUser.xp.region

If you're not basing your promotions on the order, line items, or site visitor, I'd (and I'm sure the OC team would) be very interested to know what you're basing them on!

Parallel vs Serial

Call it what you will - but being tech-minded parallel/serial resonates best with me - when combining promotions (particularly percentage-based promos) it is important to consider whether you could like to combine them all before applying the discount, or apply them one at a time.  

It is also important to make use of the Priority property to control which comes first, particularly when combining percentage + fixed values. That is,  total - 10% - $5 is not going to be the same as total - $5 - 10%.  This can all lead to some complex logic and scenarios involving cart level, line item level, auto-applied and manually-applied promotions, so my advice would be:

  1. Keep it as simple as possible, especially for Day 1 / MVP
  2. Get the business to come up with some standards, for example
    • Only 1 cart level promo can be active and auto-applied on any given day
    • Auto-applied promotions are applied first (in Priority order) then manually applied promotions in the order they are applied
      (note: this is the default, and anything else will require removing and re-adding promos)

So back to the parallel vs serial promotion scenario.  Let's consider the following examples:

  • Combined
    Subtotal$100 
    Promotion 110% off
    Promotion 27% off
    Calculation$100 - ($100*0.17)
    Total$83
  • One after the other:
    Subtotal$100 
    Promotion 110% off$100 - $10
    Promotion 27% off$90 - $6.30
    Total$83.70 

As you can see, this can lead to very different results.

By default, if you just do 2 promos with ValueExpression = order.Total * 0.1 and ValueExpression = order.Total * 0.07 for example (for 10% off and 7% off) OrderCloud will combine the promotion percentages and will apply 17% off.  Thanks to the OrderCloud team's assistance (on Sitecore Slack) and releasing a fix, we can now also do:
ValueExpression = (order.Subtotal - order.PromotionDiscount) * .10 for cart level promos or ValueExpression = (item.LineSubtotal - item.PromotionDiscount) * .7 for line items
which will calculate each promotion one after the other (or "stacking" the promotions as they referred to it).

Negative Order Totals

Something which might not immediately be apparent (but will certainly come up in testing) is the fact that there is nothing stopping your promotions from making your order total end up as a negative value!

If you have an order total of $30, and apply a promotion with ValueExpression = 50 (for $50 off) your total will end up as -$20. Not an issue with percentages, but certainly an issue for fixed value promotions.

Unfortunately the OC team replied that this would not be fixed/changed, as there are clients who need this functionality (no doubt there's a lot of use cases which other clients are using that we aren't on this particular project!) however there is a fairly easy workaround: use the min() function supported in OrderCloud expressions. 

We've implemented something like the following:

  • EligibleExpression = order.Total > 0
  • ValueExpression = min(order.Total, 50)
    (for a promo value of $50)

again, just a reminder that min() is only required for fixed values, not for percentages (which, by their very nature, can't take total below $0).

Conclusion

Promotions are a complex beast, and it's highly recommended that you get your BAs (and tech team) across exactly how you want them to function before getting stuck into the implementation; however it's great to know that OrderCloud has a very powerful and easy to use promotion system to support your requirements. While the promotion system may not always function the exact way that you need by default, it provides the features and flexibility that you need to customise it to your project's requirements; and really, at the end of the day, the same goes for OrderCloud in general!

Monday, 13 May 2024

OrderCloud Webhook model validation

We had an issue recently where we were seeing (during local testing using VS dev tunnels) that our webhook endpoint was being called but returning a 400 response, indicating that there was a model binding error (using OrderCalculatePayload).  Of course the response is being returned, but as it's being called by OrderCloud it's not actually visible to us :'(

Frustratingly, even bumping up the logging does not actually show you what the issue is, however there is a great bunch of answers in the ASP.NET Core docs issues page which does provide the necessary info. Just chuck this in your Program.cs and either set a breakpoint inside it or check your console logs.  You should be able to see exactly which property of your model (likely xp as it was in our case) is not populated as expected by the webhook call! 

builder.Services.AddControllers().AddJsonOptions(options =>
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())
).ConfigureApiBehaviorOptions(option =>
{
option.InvalidModelStateResponseFactory = context =>
{
string errorInfo = string.Empty;
try
{
if (context.ModelState.Count > 0 && context.ModelState.First().Value != null && context.ModelState.First().Value?.Errors.Count > 0)
{
errorInfo = context.ModelState.First().Value?.Errors.First().ErrorMessage ?? string.Empty;
}
}
catch (Exception ex)
{
errorInfo = $"Issue reading errorInfo {ex}";
}
Console.Error.WriteLine(errorInfo);
return new BadRequestObjectResult(context.ModelState);
};
});
view raw Program.cs hosted with ❤ by GitHub

Monday, 8 April 2024

OrderCloud .NET SDK logging

Just thought I'd drop a handy little piece of code for when you're running / debugging the OrderCloud .NET SDK locally. If you ever need to see which HTTP calls are being made, and any info about the request URL, body, response, or more importantly errors, you can just add this little snippet to your Program.cs after var app = builder.Build();

FlurlHttp.Configure(settings => settings.BeforeCall = (FlurlCall call) =>
{
app.Logger.LogInformation($"Calling {call.Request.Url} with {call.RequestBody}");
});
FlurlHttp.Configure(settings => settings.OnError = (FlurlCall call) =>
{
app.Logger.LogError(call.Exception, $"Error calling {call.Request.Url}");
});
view raw Program.cs hosted with ❤ by GitHub

Hope this helps someone! Enjoy :)

Friday, 22 March 2024

Sitecore GraphQL query - children

I thought I'd put up a friendly reminder for those who know, or some useful information for those who don't: using children in your GraphQL will (by default) only return 10 items, however the children keyword in Sitecore GraphQL is in fact a function! With this information you can find that you can filter and paginate your list of child items.

While the vast majority of examples you find are something like this:

query MyQuery($datasource: String!, $language: String!) {
datasource: item(path: $datasource, language: $language) {
# List children of given datasource
children {
results {
id
name
field(name: "MyCustomField") {
jsonValue
}
}
}
}
}

You can actually write it as:

query MyQuery($datasource: String!, $language: String!) {
datasource: item(path: $datasource, language: $language) {
# List 20 children of given template under given datasource
children(
first: 20
includeTemplateIDs: "{A6DDAC75-7D24-4BDB-B3F6-5F47FBB21379}"
# after: "endCursor" (see endCursor below)
# hasLayout: true
)
{
pageInfo {
hasNext
endCursor
}
results {
id
name
field(name: "MyCustomField") {
jsonValue
}
}
}
}
}

There really don't seem to be that many examples around which use these features of children (though there are many showing similar parameters for search), so hope this helps someone!

Thursday, 7 March 2024

OrderCloud Variants Deep-Dive

In this article I'd like to dive a bit deeper into the concept of Variants (and by assocation "Variant" Specs and Spec Options) in OrderCloud. While there is a bit of information around on the web (natually on the OrderCloud website iself) it's all fairly high level and there are a few things I discovered on an implementation recently that I thought were worth sharing.

Let's start with a quick recap of what (Variant) Specs, Spec Options, and Variants are:

  1. Product: Sellable/purchase-able item
    example: shirt, pants
  2. (Variant) Spec: A (re-usable across Products) way of splitting a Product (or multiple products) into different options
    example: Size, Colour
  3. Spec Option: Options a user can select to configure their Product
    example: S / M / L; red / green / blue
  4. Variant: Each variation of a "configurable" Product, generally with its own SKU
    examples: Small green shirt, medium blue shirt; medium green pants, large blue pants, etc.

So as you can probably tell from the examples above, Specs will generally be options which are presented to the user when viewing a specific product, and generally presented in the form of a radio list or dropdown(s); once these have been selected by the user, a specific Variant will be shown/ordered.  

As mentioned in the OrderCloud documentation on Variant Specs, it is worth noting:

Variant specs must be marked as Required as the product object by itself no longer represents a unique product, but as housing for related variant products and cannot be added to an order without specifying a specific variant product.

Selecting Your Specs (and therefore Variants)

You might think, given the example above (size + colour), that it's going to be quite obvious how you will go about splitting your Product into different Variants. Depending on how fleshed-out and/or specific your business requirements are this indeed may be the case, however it is not a guarantee.  An important consideration will be: how many of the available combinations will actually be purchaseable? Will Products actually be available in small, medium, and large for all those colour combinations?  When generating Variants using multiple Specs, on your frontend you will need to handle the dynamic showing and hiding of the different variant dropdown options.  There is also no point in generating a whole bunch of Variants, many of which you will then need to disable.  On that note - per the OrderCloud documentation Generate Variants - this is how you would need to handle any "invalid" Variants (Spec Option combinations):

In some instances, one or more of the generated variants may not represent a valid product. To manage this, any unwanted variants can be patched to have their Active property set to false.

Let me provide a different example than the standard above: what if, instead, your Product is tickets to a show (imagine a touring comedy show) and you need to be able to allow users to select their location and date. Let's say that there will be multiple locations and each location will have multiple dates available (eg. Sydney on 24th and 25th March 2024, Melbourne 15th and 18th April, etc.).  Does it make sense to have Specs/Variants for both Location and date, or does it make more sense to have a combination (location + date, call it "session") and have Sydney 24th, Sydney 25th, Melbourne 15th, etc.? In this case I would argue that it makes no sense to separate the specs into Location and Date, but to group them into a single Variant Spec and a single dropdown on the frontend.  This may, however, depend on into which other systems you are integrating.

Product vs Variant Info

There is an important decision that needs to be made, or at least an important requirement which should be documented before selecting your Specs or generating your Variants: what data needs to live at the Product level vs what data needs to live at the Variant level.  Both Product and Variant (like most OC entities) contain Name, Description, and xp properties allowing you to store information in either entity; in addition to these properties is Price, which is a separate entity. An important thing to note (more on this below) is that there are occasions in which you will need to re-populate a Variant's data.  Since everything in OC is done via API this is not necessarily painful, just something to be aware of.

Also as a part of this exercise it is important to decide / document whether inventory should be tracked at Product or Variant level. If you wish to track inventory at the Variant level, you will simply have to set:

Product.Inventory.VariantLevelTracking = true

As mentioned above, part of the requirements gathering should include documenting which information is common between variantions of a Product, and which data is specific to the Variant.

  • Shared data - which should be assigned to the Product - could include such things as: as a general description of the product, a generic image, weight of an item (if it's the same for all variations), or presenter or duration of a show.  
  • Variable data - which should be assigned to the Variant - could include such things as: a more specific image, measurements of an item of clothing (if Spec is size), venue information for a show (if Spec is location)

It is important to keep in mind: it may seem obvious, but for searching or sorting while retrieving a list of Products, you will need to use Product-level data. This may involve duplication of data, which often feels wrong. So, for example, if you want to show a list of Products on a PLP and allow the user to filter the list on a range of locations or dates (which could be Variant data) or sort by price (which would be in the Price Schedule) you will need to add these to the Product xp which can be used in the query.

Adding and Removing Variants ad-hoc

Continuing with the second Product example above (the touring show with "session" Variants), let's explore how OrderCloud handles the ad-hoc addition and removal of new Spec Options (ie. if shows sell out and so new sessions are added, or old shows are hidden/removed).

  1. First we create our Product (call it "Jason's Australian tour")
  2. Next we add our (Variant) Spec (called "AU tour sessions")
  3. We then add 2x Spec Options to start with:
    1. Sydney 24th March 2024
    2. Melbourne 15th April 2024
  4. We assign our Spec to the Product
  5. Finally, we generate our Variants

The output will be our initial list of 2 sessions which can be purchased.  

GET /me/products/:productID/variants

{
"Meta": {
"Page": 1,
"PageSize": 20,
"TotalCount": 2,
"TotalPages": 1,
"ItemRange": [
1,
2
],
"NextPageKey": null
},
"Items": [
{
"ID": "jasons-australian-tour-au-tour-melbourne",
"Name": null,
"Description": null,
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": null,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-melbourne",
"Value": "Melbourne 15th April 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": null
},
{
"ID": "jasons-australian-tour-au-tour-sydney",
"Name": null,
"Description": null,
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": null,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-sydney",
"Value": "Sydney 24th March 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": null
}
]
}

Because we merged our location and dates together into a single Spec Option, we don't have to remove or disable anything here.  We do, however, need to update the Variant to include Name, Description, and any xp we wish to use.

{
"Meta": {
"Page": 1,
"PageSize": 20,
"TotalCount": 2,
"TotalPages": 1,
"ItemRange": [
1,
2
],
"NextPageKey": null
},
"Items": [
{
"ID": "jasons-australian-tour-au-tour-melbourne",
"Name": "Melbourne 15th April 2024",
"Description": "Jason's tour of Melbourne",
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": 200,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-melbourne",
"Value": "Melbourne 15th April 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": {
"venue": "Melbourne venue info"
}
},
{
"ID": "jasons-australian-tour-au-tour-sydney",
"Name": "Sydney 24th March 2024",
"Description": "Jason's tour of Sydney",
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": 250,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-sydney",
"Value": "Sydney 24th March 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": {
"venue": "Sydney venue info"
}
}
]
}

Next up, let's imagine our tour is doing so well in Sydney that it's sold out and we need more sessions!

  1. Add one more session (Sydney 28th March 2024)
  2. Generate Variants (no "overwrite existing")

Now we have 3 Variants, with no changes to the existing previous 2.

{
"Meta": {
"Page": 1,
"PageSize": 20,
"TotalCount": 3,
"TotalPages": 1,
"ItemRange": [
1,
3
],
"NextPageKey": null
},
"Items": [
{
"ID": "jasons-australian-tour-au-tour-melbourne",
"Name": "Melbourne 15th April 2024",
"Description": "Jason's tour of Melbourne",
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": 200,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-melbourne",
"Value": "Melbourne 15th April 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": {
"venue": "Melbourne venue info"
}
},
{
"ID": "jasons-australian-tour-au-tour-sydney",
"Name": "Sydney 24th March 2024",
"Description": "Jason's tour of Sydney",
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": 250,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-sydney",
"Value": "Sydney 24th March 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": {
"venue": "Sydney venue info"
}
},
{
"ID": "jasons-australian-tour-au-tour-sydney2",
"Name": null,
"Description": null,
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": null,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-sydney2",
"Value": "Sydney 28th March 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": null
}
]
}

Let's say that it's now after 24th March and we want to stop showing the first option. If you recall above, the correct way to handle this is to set the Active property of the Variant to false. For the purposes of experimentation let's go ahead and totally remove that Spec Option just to see what happens.

  1. Remove Spec Option (Sydney 24th March)
  2. Generate Variants (no "overwrite existing")
{
"Meta": {
"Page": 1,
"PageSize": 20,
"TotalCount": 3,
"TotalPages": 1,
"ItemRange": [
1,
3
],
"NextPageKey": null
},
"Items": [
{
"ID": "jasons-australian-tour-au-tour-melbourne",
"Name": "Melbourne 15th April 2024",
"Description": "Jason's tour of Melbourne",
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": 200,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-melbourne",
"Value": "Melbourne 15th April 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": {
"venue": "Melbourne venue info"
}
},
{
"ID": "jasons-australian-tour-au-tour-sydney",
"Name": "Sydney 24th March 2024",
"Description": "Jason's tour of Sydney",
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": 250,
"Specs": [],
"xp": {
"venue": "Sydney venue info"
}
},
{
"ID": "jasons-australian-tour-au-tour-sydney2",
"Name": null,
"Description": null,
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": null,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-sydney2",
"Value": "Sydney 28th March 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": null
}
]
}

Interesting... at this point we still have 3 Variants, but 1 (the removed one) has null data. If you ever find you have a Variant without any Specs in your array, that's probably what's happened (you'll want to regenerate your Variants with "overwrite").  Let's go ahead and try with "overwrite existing".

  1. Generate Variants (with "overwrite existing")
{
"Meta": {
"Page": 1,
"PageSize": 20,
"TotalCount": 2,
"TotalPages": 1,
"ItemRange": [
1,
2
],
"NextPageKey": null
},
"Items": [
{
"ID": "jasons-australian-tour-au-tour-melbourne",
"Name": null,
"Description": null,
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": null,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-melbourne",
"Value": "Melbourne 15th April 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": null
},
{
"ID": "jasons-australian-tour-au-tour-sydney2",
"Name": null,
"Description": null,
"Active": true,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Inventory": null,
"Specs": [
{
"SpecID": "au-tour-sessions",
"Name": "AU tour sessions",
"OptionID": "au-tour-sydney2",
"Value": "Sydney 28th March 2024",
"PriceMarkupType": "NoMarkup",
"PriceMarkup": null
}
],
"xp": null
}
]
}

And we're back to the 2 remaining Variants as expected, however with none of the data (Name, Description, xp) present. This highlights one of the challenges of working with Variants, and why we should use the active property instead of removing Spec Options and regenerating Variants.  Also keep in mind that there are other cases where you will need to regenerate Variants, such as changing Spec Option ID, so try to think about your Variants in advance! Rest easy, however, as simply changing some details of a Spec / Spec Option such as Name or Description (or even PriceMarkup) will not mean that you need to regenerate Variants, so no need to stress if you make a typo.

Pricing

Perhaps unfortunately for some (myself included), a Price Schedule can only be assigned at the Product level, and not to a Variant (see my next post on other options which support Price Schedules). There is a workaround, however, in the form of price markup (which includes the fields PriceMarkupType and PriceMarkup). Despite the name, it does not need to be an increase since it can be set to a negative value. One major limitation you should be aware of at this point: price markups do not support multi-currency.

Somewhat unintuitively (at least in my opinion) the price markup can only be set on a Spec Option (which is used to generate the Variant).  It can be retrieved from the Variant under the Specs array property.  Once you get your head around the fact that pricing is set and retrieved at the Spec Option level, it makes sense that when adding a line item to your Order, you must pass the selected Spec Options (and the appropriate Variant is automatically calculated by OrderCloud). 

As the price markup documentation includes some good examples of the various price markup options, let's have a look at what happens if we have a Variant generated from multiple Spec Options and add that as a Line Item to our Order:

  1. Product - Price $10
  2. Spec Option Medium - PriceMarkupType AmountTotal - PriceMarkup 2
  3. Spec Option Black - PriceMarkupType Percentage - PriceMarkup -50
  4. Final line item price: $7

Interestingly this seems to indicate a PriceMarkupType of Percentage is executed first.

Should your requirement to be to handle amounts before percentage, the OrderCloud support team have recommended changing the markup type of Percentage to be an auto-apply line item level promotion. I haven't tried this out, but it seems like a good workaround.

Should you need to change the PriceMarkup on your Spec Options, these are reflected immediately in the Cart (ie unsubmitted Order) but do not affect submitted Orders, as one would expect.

Events

Product Sync is triggered on (at least):

  1. Assigning a Spec to Product
  2. Updating a Spec already assigned to a Product
  3. Generating Variants

if the Product data changes - ie it will not be triggered if your 'generate variants' does not actually overwrite/modify anything. The resulting message contains the data of the updated Product.

You can see a sample of a message below:

{
"Headers": {
"type": "sitecore.ordercloud.messages.product.updated",
"marketplace": "yourMarketplaceId",
"tenantid": "yourMarketplaceId"
},
"Payload": {
"UserContext": {
"ID": null,
"AnonymousID": null
},
"ProductID": "test",
"Marketplace": "yourMarketplaceId",
"OwnerID": "yourMarketplaceId",
"Name": "Variant Test",
"Description": "Product for testing variants",
"QuantityMultiplier": 1,
"ShipWeight": null,
"ShipHeight": null,
"ShipWidth": null,
"ShipLength": null,
"Active": true,
"AutoForward": false,
"SpecCount": 3,
"VariantCount": 3,
"ShipFromAddressID": null,
"Inventory": null,
"DefaultSupplierID": null,
"AllSuppliersCanSell": false,
"DefaultPriceScheduleID": null,
"Returnable": false,
"xp": {},
"Catalogs": [
"catalog_au"
],
"Categories": [
{
"ID": "category_au",
"Name": "Australian",
"ListOrder": 1
}
],
"Suppliers": [
"yourMarketplaceId"
],
"Buyers": [
"au-buyers"
],
"UserGroups": [
"au-group"
],
"Specs": [
{
"OwnerID": "yourMarketplaceId",
"ID": "size",
"ListOrder": 1,
"Name": "Size",
"DefaultValue": null,
"Required": true,
"AllowOpenText": false,
"DefaultOptionID": null,
"DefinesVariant": true,
"xp": null,
"OptionCount": 3,
"Options": [
{
"ID": "small",
"Value": "Small",
"ListOrder": 1,
"IsOpenText": false,
"PriceMarkupType": 0,
"PriceMarkup": null,
"xp": null
},
{
"ID": "medium",
"Value": "Medium",
"ListOrder": 2,
"IsOpenText": false,
"PriceMarkupType": 2,
"PriceMarkup": 2.0,
"xp": null
},
{
"ID": "large",
"Value": "Large",
"ListOrder": 3,
"IsOpenText": false,
"PriceMarkupType": 2,
"PriceMarkup": 5.0,
"xp": null
}
]
},
{
"OwnerID": "yourMarketplaceId",
"ID": "colour",
"ListOrder": 2,
"Name": "Colour",
"DefaultValue": null,
"Required": true,
"AllowOpenText": false,
"DefaultOptionID": null,
"DefinesVariant": true,
"xp": null,
"OptionCount": 1,
"Options": [
{
"ID": "black",
"Value": "Black",
"ListOrder": 1,
"IsOpenText": false,
"PriceMarkupType": 3,
"PriceMarkup": -50.0,
"xp": null
}
]
},
{
"OwnerID": "yourMarketplaceId",
"ID": "newspec",
"ListOrder": 0,
"Name": "New Spec to trigger Sync",
"DefaultValue": null,
"Required": false,
"AllowOpenText": false,
"DefaultOptionID": null,
"DefinesVariant": false,
"xp": null,
"OptionCount": 1,
"Options": [
{
"ID": "HNkDeMP890iU9qRYaSRpNA",
"Value": "New Spec Option",
"ListOrder": 0,
"IsOpenText": false,
"PriceMarkupType": 0,
"PriceMarkup": null,
"xp": null
}
]
}
],
"DefaultPriceSchedule": null,
"PartyPriceSchedules": [
{
"Seller": "yourMarketplaceId",
"Party": "au-group",
"PartyType": 2,
"PriceSchedule": {
"OwnerID": "yourMarketplaceId",
"ID": "test_price",
"Name": "Product Price",
"ApplyTax": false,
"ApplyShipping": false,
"MinQuantity": 1,
"MaxQuantity": null,
"UseCumulativeQuantity": false,
"RestrictedQuantity": false,
"PriceBreaks": [
{
"Quantity": 1,
"Price": 10.0,
"SalePrice": null,
"SubscriptionPrice": null,
"BundlePrice": null
}
],
"Currency": "AUD",
"SaleStart": null,
"SaleEnd": null,
"IsOnSale": false,
"xp": null
}
}
],
"SellerDefaultPriceSchedules": null,
"InventoryRecords": null
}
}

Conclusion

(Spec) Variants are a quick and easy way to split your Products - with options that users can choose - into multiple options each with their own SKU.  It's a great option for basic usage, but does have its limitations for more complex scenarios; most notably: there are situations where Variants must be re-generated, meaning they lose their data (which needs to be re-added); pricing is not very easy to work with and does not support multi-currency. You should definitely think through your Spec/Option/Variant strategy as you document business requirements and not leave it to the last minute. 

Alternatives

Stay tuned to my next article where I will discuss other options and how they compare!