

.png)
In Agentforce Revenue Management, the catalog decides what a rep can put on a quote. When a product is discontinued, it should disappear from the catalog – but not for the customer who has been paying for it, let’s say, for years and is about to renew.
Both things are true at once: hide the product from new deals, keep it visible to the accounts that own it.
The usual alternative is a custom-built catalog component– a heavy build most teams would rather avoid. This article shows how to do that with Product Qualification – disqualifying end-of-life (EOL) products in general, while keeping the ones on an active contract.
The straightforward way to retire a product is to mark it inactive or let its end-of-life date pass, and let the catalog filter it out. That works for exactly one audience: prospects who have never bought it.
For an existing customer, the same product is still live. It sits on an active contract, it is being invoiced, and it will need to appear on the renewal quote and on amendments to the current term. If the catalog cannot show it, the rep works around the system – manual line entry, a clone of an old quote, a separate price book for legacy accounts. Each workaround puts data outside the pricing and configuration engine, which is where the real cost shows up later.
The requirement, stated properly, is conditional visibility: hide products from the catalog for everyone except the accounts that already own them under an active contract.
.png)
ARM runs a Qualification Procedure for every product in the catalog during discovery. The procedure decides whether each product is qualified – that is, whether the user sees it. This is the correct place for the rule, because it runs per product per session rather than as a static property of the product record.
What the procedure needs is context: it must know something about the account currently browsing. That is what the Context Service provides. A Context Definition describes the data structure available to the runtime, and a Context Data Input Array lets a Flow push additional values into that structure at the moment the catalog opens.
So the design has three moving parts:
The rest of this article walks through each one.
Four fields support the logic. Two of them are the payload, two of them are the rule.
Two things to note here.
ActiveContractProductIds__c on Account is working storage, not master data. It holds the list assembled for the current catalog session. It is not a field anyone maintains by hand and it is not a field to report on.IsEndOfLifeCheck__c on Product Qualification is what keeps the rule from applying to the whole catalog. Products that were never meant to be retired are simply not subject to the check, and they qualify without touching the account list at all.After creating the fields, two setup steps follow:
ThisProductId__c and IsEndOfLifeCheck__c on the Product Qualification records for the products in scope.The Context Definition is the bridge between the Flow and the discovery engine. Without it, the Apex output has nowhere to go.
Add two tags to the Context Definition:
ActiveContractProductIds__c – Node: Account, Type: String, Direction: Input/OutputIsEndOfLife__c – Node: CategoryProduct, Type: Boolean, Direction: Input/OutputMap each tag to its Salesforce field:
Account.ActiveContractProductIds__c → ActiveContractProductIds__cProduct2.IsEndOfLife__c → IsEndOfLife__cThe two tags sit on different nodes for a reason. The owned-products list is a property of the account and is the same for every product evaluated in the session. The EOL status belongs to the product being evaluated.
BuildContextDataInputArray is invoked from the Catalog Flow. It takes an account ID, finds every product that account holds under an active contract, and returns it as a ContextDataInput the runtime can consume.
public with sharing class BuildContextDataInputArray {
public class FlowInput {
@InvocableVariable(required = true)
public String accountId;
}
public class FlowOutput {
@InvocableVariable
public List<runtime_industries_cpq.ContextDataInput> contextDataInputArray =
new List<runtime_industries_cpq.ContextDataInput>();
}
@InvocableMethod(
label = 'Build Context Data Input Array',
description = 'Creates the Array of ContextDataInput for additional Context Data'
)
public static List<FlowOutput> build(List<FlowInput> inputs) {
FlowOutput result = new FlowOutput();
runtime_industries_cpq.ContextDataInput data =
new runtime_industries_cpq.ContextDataInput();
String accountId = inputs[0].accountId;
List<String> productIds = new List<String>();
for (AggregateResult ar : [
SELECT Product2Id
FROM OrderItem
WHERE Order.AccountId = :accountId
AND Order.Status = 'Activated'
AND Order.Contract.Status = 'Activated'
AND Order.Contract.StartDate <= TODAY
AND Order.Contract.EndDate >= TODAY
GROUP BY Product2Id
]) {
productIds.add((String) ar.get('Product2Id'));
}
data.nodeName = 'Account';
data.nodeData = new Map<String, Object>{
'ActiveContractProductIds__c' => String.join(productIds, ';')
};
result.contextDataInputArray.add(data);
return new List<FlowOutput>{ result };
}
}
The query is an aggregate on OrderItem grouped by Product2Id, which returns each product once regardless of how many order lines reference it. The filters define what “currently owns” means: the order is activated, the contract behind it is activated, and today falls inside the contract term.
Those filters are the business definition, and they are the part most likely to need adjusting. If your org treats a contract in its renewal window as still active, or recognizes ownership from Assets rather than Orders, this is the method to change. The rest of the pattern stays as it is.
The result is joined into a single semicolon-separated string and written to the Account node. One string, one context entry, evaluated against every product in the catalog.
The rule itself lives in a Decision Table rather than in Apex, which keeps it visible and editable to admins.
Name: Active_Contract_Product_Qualification_DT
Source Object: ProductQualification
The table uses Custom Condition Logic (2 OR 3). Read it as: the product qualifies if it appears in the account’s active list, or if it is not subject to the EOL check.
The Contains operator on row 2 is what makes the semicolon-separated string work – the product ID is matched against the concatenated list rather than against individual records. It also explains why the string is assembled in Apex rather than passed as a collection: the operator needs something it can search.
Row 3 is what keeps product disqualification limited to the products you actually retired. Products without the EOL check flagged pass on that condition alone and never depend on purchase history.
The last step wires the pieces into the Product Catalog Flow:
contextDataInputArray of type runtime_industries_cpq.ContextDataInput.accountId.contextDataInputArray.contextDataInputArray into the Context Data Input Array property.Step 4 is the one that is easy to miss. The Apex can run correctly and the variable can be populated, and nothing will change in the catalog until the array is actually handed to the Product List component.
The diagram below traces one catalog session across all six participants – the Flow, the Apex class, the database, the Product List component, the discovery engine, and the Decision Table.

End-to-end sequence: from Browse Catalogs to a filtered product list
Fifteen steps, but only three of them carry the logic. The rest is plumbing – passing values between components that already know what to do with them.
A discontinued product is therefore invisible to a prospect and visible to the customer holding it on a live contract, from the same catalog, with no branching UI and no second price book.
All four come down to your org's data – how much of it there is, and how it defines what a customer owns.
The context builder runs on every catalog load, and the qualification evaluates against the injected data for every product. On large product sets, or with a heavier query than the one above, this can add measurable time to discovery. Test with production-scale data early rather than with a handful of sample records.
ActiveContractProductIds__c is a Long Text Area, which has a maximum length. The Apex joins every owned product ID into one string, so the length grows with the account's purchase history. If a single account's history is large enough to approach that limit, size the field accordingly.
The SOQL above reads ownership from activated Orders under activated Contracts. That is one reasonable definition, not the only one. Confirm it against how your org records what a customer holds before treating the query as final.
A product that becomes visible through this mechanism still needs a valid, current price entry to be added to a quote. Product EOL is often accompanied by price book cleanup, and the two changes need to stay coordinated.
The configuration path is smaller than it looks: four fields, two context tags, one Apex class, one Decision Table, and four steps in the Catalog Flow. And end of life is only the example – once account-level data reaches the qualification context, the same structure drives availability by region, entitlement, or customer tier.
The alternative is a custom catalog component, and that estimate is usually where the requirement gets dropped. If you are looking at a requirement that does not fit the standard catalog, the Veloce team can help.
What does product qualification do in Agentforce Revenue Management?
It decides, at runtime, whether a given product is shown to the user browsing the catalog. The Qualification Procedure evaluates each product in the catalog for the current session, which is what makes per-account availability rules possible.
Why not just deactivate the product or remove it from the price book?
Both apply globally. Deactivating hides the product from existing customers too, which breaks renewals and amendments for accounts that still hold it on a live contract.
What is a Context Data Input Array used for?
It lets a Flow inject additional data into the discovery context at runtime. Here it carries the list of products the account already owns, so the qualification logic has something account-specific to evaluate against.
Why is the product list a semicolon-separated string rather than a collection?
Because the Decision Table matches it with the Contains operator, which needs a searchable value. The string is assembled once per session and compared against each product’s ID during qualification.
Does this affect catalog performance?
It can. The Apex runs on every catalog load and the qualification evaluates per product. The impact depends on catalog size and the complexity of the query, so test with realistic data volumes before go-live.
Can this approach handle rules other than end of life?
Yes. The approach is not specific to EOL – the Decision Table conditions change, the architecture stays the same. What can drive availability depends on what your Context Definition exposes.