Class Name: ProductController¶
Last Updated: 2025-10-22 Source Code: https://github.com/AANP-IT/I2C.Salesforce.Metadata/blob/STAGING/force-app/main/default/classes/ProductController.cls
API Name: ProductController Type: Controller (LWC) Test Coverage: To be determined
Business Purpose¶
The ProductController class provides comprehensive product, pricing, and PAC (Political Action Committee) contribution services for the B2B Commerce storefront. This supports:
- Product catalog browsing and details
- International membership pricing (Tier 1/2/3 country groups)
- Auto-renewal coupon management
- PAC contribution tracking and recurring payments
- Buyer group access control
- Community product visibility
- Digital product downloads
- Price calculation for different pricebooks
This is a central controller for product-related LWC components on the customer portal.
Class Overview¶
- Author: Ecaterina Popa
- Created: 08/15/2024
- Last Modified: 11/06/2025 by Antoneac Victor
- Test Class: ProductControllerTest (likely)
- Scope/Sharing:
with sharing- Respects record-level security - Key Responsibilities:
- Query products with various filters
- Calculate international pricing based on country tiers
- Manage PAC contribution products and totals
- Control buyer group access to products
- Retrieve recurring Chargent orders for PAC
- Sync account communities to products
Constants & Cache¶
@TestVisible
private static Map<String, String> communityIdToWebStoreIdCache = new Map<String, String>();
Purpose: Caches community ID to WebStore ID mappings for performance (lines 15-16).
Public Methods¶
resolveCommunityIdToWebstoreId¶
Purpose: Resolves Experience Cloud community ID to WebStore ID with caching.
Business Logic (lines 25-43):
try {
if (communityIdToWebStoreIdCache.containsKey(communityId)) {
return communityIdToWebStoreIdCache.get(communityId);
} else {
String webStoreId = [
SELECT WebStoreId
FROM WebStoreNetwork
WHERE NetworkId = :communityId
LIMIT 1
]
.WebStoreId;
communityIdToWebStoreIdCache.put(communityId, webStoreId);
return webStoreId;
}
} catch (Exception e) {
return e.getMessage();
}
Issues/Concerns: - ⚠️ Returns Error Message as String (line 41): Calling code can't distinguish between valid WebStoreId and error - Should throw exception instead of returning error message - Error message could be confused with valid Id - ✅ Caching: Reduces SOQL queries for repeated calls - ⚠️ @TestVisible Cache: Test methods can manipulate cache (good for testing, but make sure tests clear it)
getProductDetails¶
@AuraEnabled(cacheable=true)
public static ConnectApi.ProductDetail getProductDetails(String communityId, String productId)
Purpose: Retrieves product details using Commerce Connect API.
Business Logic (lines 53-76):
try {
String webstoreId = ProductController.resolveCommunityIdToWebstoreId(communityId);
return ConnectApi.CommerceCatalog.getProduct(
webstoreId,
productId,
null,
null,
false,
null,
false,
true,
false
);
} catch (Exception e) {
return null;
}
Issues/Concerns: - ⚠️ Returns null on Error (line 74): LWC won't know why it failed - Should throw AuraHandledException - ⚠️ webstoreId Could be Error Message: If resolveCommunityIdToWebstoreId returns error, ConnectApi call will fail
getMembershipProducts¶
Purpose: Retrieves specific membership products by hardcoded SKU list (lines 84-128).
Hardcoded SKUs (lines 87-100):
List<String> productSKUs = new List<String>{
'P-MEM-IND-002',
'P-MEM-IND-003',
'P-MEM-IND-004',
'P-MEM-IND-006',
'P-MEM-IND-007',
'P-MEM-IND-008',
'P-MEM-IND-244',
'P-MEM-IND-245',
'P-MEM-DISCOUNT-215',
'P-FELO-075',
'P-FELO-074',
'P-FELO-076'
};
Query (lines 103-122):
if (Schema.sObjectType.Product2.isAccessible()) {
prodList = [
SELECT
Id,
Name,
WebName__c,
Family,
Product_Category__c,
StockKeepingUnit,
IsActive,
WebEnabled__c,
IsSold__c,
Member_Type__c,
Auto_Renewal_Discount_Percent__c
FROM Product2
WHERE
StockKeepingUnit IN :productSKUs
AND IsActive = TRUE
AND WebEnabled__c = TRUE
AND IsSold__c = TRUE
];
}
Issues/Concerns: - 🚨 HIGH: Hardcoded SKU list should be in custom metadata - Changes require code deployment - No flexibility for admins - ✅ FLS Check: Uses Schema.sObjectType.Product2.isAccessible() - ⚠️ Returns null on Error (line 126): Should throw AuraHandledException
getProductsByFamily¶
Purpose: Queries products by Family field (lines 137-164).
Similar Pattern: FLS check, active/web-enabled filters, returns null on error.
getMembershipAutoRenewalCoupon¶
Purpose: Retrieves auto-renewal coupon by custom checkbox field (lines 172-191).
Query (lines 176-186):
Coupon coupon = new Coupon();
coupon = [
SELECT
Id,
Name,
CouponCode,
Status,
PromotionId,
Membership_Auto_Renewal_Coupon__c
FROM Coupon
WHERE Membership_Auto_Renewal_Coupon__c = TRUE AND Status = 'Active'
];
Issues/Concerns:
- ⚠️ No LIMIT Clause: Assumes only one coupon has flag = true
- Could throw "List has more than 1 row" exception
- Add LIMIT 1
- ⚠️ Unnecessary Variable (line 176): new Coupon() overwritten by query
getPromotionTarget¶
@AuraEnabled(cacheable=true)
public static PromotionTarget getPromotionTarget(String promotionId, String productId)
Purpose: Gets discount details for specific promotion and product (lines 201-223).
Same Issues: No LIMIT clause, returns null on error.
getProductBestPrice¶
@AuraEnabled(cacheable=true)
public static ConnectApi.ProductPrice getProductBestPrice(String communityId, String productId, String effectiveAccountId)
Purpose: Retrieves best price for product using Commerce Connect API.
Guest User Handling (lines 245-247):
if(effectiveAccountId == 'guest_user'){
effectiveAccountId = [SELECT Id FROM GuestBuyerProfile LIMIT 1].Id;
}
Issues/Concerns: - ⚠️ Magic String (line 245): 'guest_user' should be constant - ⚠️ No LIMIT on Query: GuestBuyerProfile query could fail if multiple profiles exist - ✅ Guest Support: Properly handles anonymous users
getInternationalMembershipPrice¶
@AuraEnabled(cacheable=true)
public static PriceBookEntry getInternationalMembershipPrice(String productId, String countryCode)
Purpose: Retrieves international membership pricing based on country tier (lines 267-302).
Business Logic:
- Check Metadata Access (lines 270-275):
-
Uses metadata types for country groupings
-
Determine Country Tier (lines 279-292):
List<Countries_Group_1__mdt> group1Country = [SELECT Id, ISOCode__c FROM Countries_Group_1__mdt WHERE ISOCode__c =: countryCode]; if (group1Country.size() > 0){ countryGroupPricebook = ProductController.getPricebookByType('Tier 1 Countries'); } else { List<Countries_Group_2__mdt> group2Country = [SELECT Id, ISOCode__c FROM Countries_Group_2__mdt WHERE ISOCode__c =: countryCode]; if (group2Country.size() > 0){ countryGroupPricebook = ProductController.getPricebookByType('Tier 2 Countries'); } else { List<Countries_Group_3__mdt> group3Country = [SELECT Id, ISOCode__c FROM Countries_Group_3__mdt WHERE ISOCode__c =: countryCode]; if (group3Country.size() > 0){ countryGroupPricebook = ProductController.getPricebookByType('Tier 3 Countries'); } } } - Nested if-else to determine tier
-
Queries metadata for each tier sequentially
-
Get Price Entry (lines 293-297):
Issues/Concerns: - ⚠️ Three Sequential Queries (lines 279, 283, 287): Could be optimized - Consider single SOQL with OR condition across all three metadata types - Or use Map-based approach - ⚠️ Hardcoded Pricebook Types (lines 281, 285, 289): 'Tier 1/2/3 Countries' should be constants - ✅ Proper Error Handling: Throws AuraHandledException with clear message - ✅ Metadata-Driven: Country tiers are configurable via custom metadata
getPacContributionProduct¶
Purpose: Retrieves single PAC contribution product by SKU 'P-PAC-032' (lines 362-374).
Issues/Concerns: - ⚠️ Hardcoded SKU (line 365): Should be in custom setting or metadata - ⚠️ cacheable=false: Could be cacheable since product rarely changes - ✅ Null Safety: Checks list size before accessing
getPacContributionProducts¶
Purpose: Retrieves all PAC contribution products as Map keyed by SKU (lines 377-410).
PAC SKUs (lines 381-386):
Set<String> pacContributionSKU = new Set<String>{
'P-PAC-001',
'P-PAC-998',
'P-PAC-997',
'P-PAC-032'
};
Issues/Concerns: - 🚨 HIGH: Hardcoded SKUs should be in custom metadata - ✅ Map Pattern: Allows easy SKU-based lookup - ✅ String.isNotBlank Check: Validates SKU before adding to map
getTotalAmountPacContribution¶
@AuraEnabled
public static Decimal getTotalAmountPacContribution(String pacContributionId, String personAccountId)
Purpose: Calculates total PAC contributions for current year (lines 413-435).
Query (lines 416-422):
List<OrderItem> pacOrderItemList = [SELECT Id, Order.Status, Quantity, TotalPrice, Order.ActivatedDate
FROM OrderItem
WHERE Product2Id =: pacContributionId
AND Order.Status = 'Activated'
AND Quantity > 0
AND Order.ActivatedDate = THIS_YEAR
AND Order.AccountId =: personAccountId];
Aggregation (lines 424-428):
Decimal totalAmount = 0;
for(OrderItem orderItem : pacOrderItemList){
totalAmount += orderItem.TotalPrice;
}
return totalAmount;
Issues/Concerns:
- ⚠️ Manual Aggregation: Should use SOQL aggregate query
- SELECT SUM(TotalPrice) FROM OrderItem WHERE...
- More efficient than Apex loop
- ✅ Proper Filtering: THIS_YEAR, Activated status, Quantity > 0
getTodayContribution¶
Purpose: Calculates today's PAC contributions for cash payments only (lines 437-494).
Complex Logic:
- Get PAC Product Ids (lines 443-451):
-
Uses Family or SKU pattern match
-
Filter Today's Activated Orders (lines 458-466):
-
Find Cash Payment Chargent Orders (lines 467-474):
-
Only includes cash payments (not credit card)
-
Aggregate Today's Contribution (lines 477-486):
Issues/Concerns: - ⚠️ Four Queries (lines 445, 458, 467, 477): Could be optimized - ⚠️ Only Cash Payments: Excludes credit card contributions made today - Business rule or bug? - ✅ Aggregate Query: Proper use of SUM for efficiency - ✅ Manual Order Filter: Includes Manual_Order__c = true
checkBuyerGroupAccess¶
Purpose: Determines if account has buyer group access to product (lines 545-610).
Business Logic:
-
Guest Check (lines 547-549):
-
Get Account Buyer Groups (lines 557-575):
-
BuyerGroupIds__c is comma-separated string of Ids
-
Get Product Visible Buyer Groups (lines 582-601):
-
Compare Sets (lines 604-608):
Return Values: - 'LOGIN_REQUIRED' - Guest user - 'ACCESS_DENIED-NULL' - Missing parameters - 'ACCESS_DENIED-NoAccount' - Account not found - 'ACCESS_DENIED-NoBuField' - BuyerGroupIds__c is blank - 'ACCESS_DENIED-EmptyBGSet' - No buyer groups after parsing - 'ACCESS_DENIED-NoVisibleBgs' - Product has no visible buyer groups - 'ACCESS_GRANTED' - Match found - 'ACCESS_DENIED-LAST' - No match
Issues/Concerns: - ⚠️ String Return Values: Should use enum or constants - ⚠️ Comma-Separated String Fields: BuyerGroupIds__c and VisibleToBuyerGroups__c - Should use junction objects instead - Limits to ~255 characters - No referential integrity - ⚠️ Multiple Return Codes: 7 different return codes make LWC logic complex - ✅ Detailed Error Codes: Helpful for debugging - ✅ Guest Check: Proper UserInfo.getUserType() usage
getTotalAmountPacContributionNew¶
@AuraEnabled
public static Map<String, Decimal> getTotalAmountPacContributionNew(String pacContributionId, String personAccountId)
Purpose: Calculates total PAC contributions including future recurring payments (lines 732-820).
Returns Map: - 'totalAmount' - Activated orders this year - 'totalFutureAmount' - Estimated future recurring payments this year - 'totalChargedAmount' - Already charged amount from recurring orders
Complex Business Logic:
-
One-Time Contributions (lines 758-773):
List<OrderItem> pacOrderItemList = [ SELECT Id, Order.Status, Quantity, TotalPrice, Order.ActivatedDate FROM OrderItem WHERE Product2Id IN :pacProductIds AND Order.Status = 'Activated' AND Quantity > 0 AND Order.ActivatedDate = THIS_YEAR AND Order.AccountId = :personAccountId ]; if(pacOrderItemList != null && pacOrderItemList.size() > 0){ for (OrderItem item : pacOrderItemList) { totalAmount += item.TotalPrice; } totalAmountActivatedOrders = totalAmount; } -
Calculate Future Recurring Contributions (lines 775-810):
Date today = Date.today(); Integer currentYear = today.year(); Date endOfYear = Date.newInstance(currentYear, 12, 31); List<ChargentOrders__ChargentOrder__c> activeRecurringOrders = getChargentOrders(personAccountId); for (ChargentOrders__ChargentOrder__c order : activeRecurringOrders) { if (order.ChargentOrders__Payment_Start_Date__c == null) { continue; } Date startDate = order.ChargentOrders__Payment_Start_Date__c; String frequency = order.ChargentOrders__Payment_Frequency__c; Decimal amountPerPayment = order.ChargentOrders__Charge_Amount__c; Decimal chargedTotalAmountPerOrder = order.ChargentOrders__Transaction_Total__c; Integer intervalMonths; if (frequency == 'Monthly') {intervalMonths = 1;} else if (frequency == 'Quarterly') {intervalMonths = 3;} else if (frequency == 'Annual') {intervalMonths = 12;} else {continue;} Date firstPayment = startDate > today ? startDate : today; Date recurringDate = firstPayment; Integer futureCount = 0; while (recurringDate <= endOfYear) { futureCount++; recurringDate = recurringDate.addMonths(intervalMonths); } Decimal estimatedFutureAmount = (amountPerPayment * futureCount) - chargedTotalAmountPerOrder; totalAmount += estimatedFutureAmount; totalChargedAmount += chargedTotalAmountPerOrder; } - Calculates remaining payments in current year
- Subtracts already-charged amount
Issues/Concerns: - ⚠️ Complex Calculation: Future payment estimation could be inaccurate - Doesn't account for payment failures - Assumes regular payment schedule - ⚠️ Manual Loop Aggregation: Could use aggregate queries - ✅ Frequency Handling: Supports Monthly, Quarterly, Annual - ✅ Subtracts Charged Amount: Avoids double-counting
getScheduledOrders¶
@AuraEnabled(cacheable=true)
public static List<ScheduledOrderInfo> getScheduledOrders(String personAccountId)
Purpose: Returns scheduled PAC recurring orders with frequency and total (lines 836-866).
ScheduledOrderInfo Inner Class (lines 822-834):
public class ScheduledOrderInfo {
@AuraEnabled public Id orderId;
@AuraEnabled public Decimal amount;
@AuraEnabled public Integer frequency;
@AuraEnabled public Decimal total;
}
Calculation (lines 840-863):
for (ChargentOrders__ChargentOrder__c order : getChargentOrders(personAccountId)) {
Integer freq = 0;
if (order.ChargentOrders__Payment_Frequency__c == 'Monthly') {freq = 1;}
else if (order.ChargentOrders__Payment_Frequency__c == 'Quarterly') {freq = 3;}
else if (order.ChargentOrders__Payment_Frequency__c == 'Annual') {freq = 12;}
else {continue;}
Decimal amount = order.ChargentOrders__Charge_Amount__c != null
? order.ChargentOrders__Charge_Amount__c
: 0;
Decimal calculatedTotal = 0;
if(order.ChargentOrders__Payment_Frequency__c == 'Annual'){
calculatedTotal = amount;
} else {
calculatedTotal = amount * freq;
}
result.add(new ScheduledOrderInfo(
order.Id,
amount,
freq,
calculatedTotal
));
}
Issues/Concerns:
- ⚠️ Annual Calculation (line 852): calculatedTotal = amount (no multiplication)
- But freq = 12 for Annual
- Inconsistent logic - should be amount * 1 or just amount?
- ⚠️ Frequency as Integer: Stores months (1, 3, 12) not payment count
- Variable name freq is misleading
getRecurringChargentOrders¶
@AuraEnabled(cacheable=true)
public static List<ChargentOrders__ChargentOrder__c> getRecurringChargentOrders(Id accountId)
Purpose: Queries recurring PAC Chargent orders for account (lines 684-710).
Filters (lines 704-708):
WHERE ChargentOrders__Account__c = :accountId
AND ChargentOrders__Payment_Frequency__c != 'Once'
AND ChargentOrders__Payment_Status__c = 'Recurring'
AND ChargentOrders_Recurring_Pac__c = true
AND ChargentOrders__Payment_Start_Date__c = THIS_YEAR
updateChargentOrders¶
@AuraEnabled
public static void updateChargentOrders(List<ChargentOrders__ChargentOrder__c> updatedOrders)
Purpose: Updates Chargent orders (line 712-715).
Issues/Concerns: - ⚠️ No Validation: Accepts any field updates without validation - ⚠️ No Error Handling: DML exception bubbles to LWC - ⚠️ Security: Users can update any Chargent order fields - Should validate which fields can be updated - Should check ownership
getAccountCommunityProducts¶
@AuraEnabled(cacheable=true)
public static List<Product2> getAccountCommunityProducts(String accountId)
Purpose: Retrieves products matching account's Higher Logic community memberships (lines 887-913).
Business Logic (lines 893-900):
Account accRecord = [SELECT Id, Higher_Logic_Communities__c FROM Account WHERE Id = :accountId LIMIT 1];
// Split the semicolon-separated values into a list
if (String.isNotBlank(accRecord.Higher_Logic_Communities__c)) {
selectedValues = accRecord.Higher_Logic_Communities__c.split(';');
}
Query (lines 916-924):
return [
SELECT Id, Name, WebName__c, Family, Product_Category__c, IsActive, WebEnabled__c, IsSold__c
FROM Product2
WHERE Name IN :accountCommunityProducts
AND IsActive = TRUE
AND WebEnabled__c = TRUE
AND IsSold__c = TRUE
];
Issues/Concerns: - ⚠️ Name Matching: Assumes Product2.Name exactly matches picklist values - Fragile - name changes break matching - Should use SKU or external Id - ✅ Multi-Select Picklist Pattern: Proper split(';') usage
Dependencies¶
Salesforce Objects¶
- Product2 (Standard Object): All product queries
- Pricebook2 (Standard Object): International pricing
- PricebookEntry (Standard Object): Price entries
- Coupon (Commerce Object): Auto-renewal coupons
- PromotionTarget (Commerce Object): Discounts
- Order (Standard Object): PAC contribution tracking
- OrderItem (Standard Object): Line item totals
- Account (Standard Object): Buyer groups, communities
- ChargentOrders__ChargentOrder__c (Chargent Package): Recurring payments
- WebStoreNetwork (Commerce Object): Community to WebStore mapping
- GuestBuyerProfile (Commerce Object): Guest user pricing
Custom Metadata¶
- Countries_Group_1__mdt: Tier 1 country list
- Countries_Group_2__mdt: Tier 2 country list
- Countries_Group_3__mdt: Tier 3 country list
Custom Fields¶
- Product2.WebEnabled__c, IsSold__c, Member_Type__c, Auto_Renewal_Discount_Percent__c, Product_Download_Url__c, IsDownloadableProduct__c, VisibleToBuyerGroups__c
- Pricebook2.Type__c
- Coupon.Membership_Auto_Renewal_Coupon__c
- Order.Manual_Order__c
- Account.PAC_Contribution_Type__c, isRecurring__c, BuyerGroupIds__c, Higher_Logic_Communities__c, PersonId__c, BusinessId__c
- ChargentOrders__ChargentOrder__c.ChargentOrders_Recurring_Pac__c
ConnectApi¶
- ConnectApi.CommerceCatalog.getProduct
- ConnectApi.CommerceStorePricing.getProductPrice
Design Patterns¶
- LWC Controller Pattern: @AuraEnabled methods for components
- Caching Pattern: communityIdToWebStoreIdCache for performance
- FLS Pattern: Schema.sObjectType checks for object access
- DTO Pattern: ScheduledOrderInfo wrapper class
- Metadata-Driven: Country tier pricing via custom metadata
- Multi-Tier Pricing: Pricebook2 Type__c field for country groups
Pre-Go-Live Concerns¶
🚨 CRITICAL¶
- Hardcoded SKU Lists (lines 87-100, 381-386, 365): Move to custom metadata
- Cannot change without code deployment
- Multiple locations need sync
- updateChargentOrders Security (lines 712-715): No field validation or ownership check
- Users can update any Chargent order fields
- Add field-level security checks
HIGH¶
- Error Handling Pattern (multiple methods): Returns null instead of throwing exception
- LWC can't distinguish errors from no data
- Change to throw AuraHandledException
- resolveCommunityIdToWebstoreId (line 41): Returns error message as String
- Calling code treats it as valid WebStoreId
- Throw exception instead
- Comma-Separated Id Fields (checkBuyerGroupAccess): BuyerGroupIds__c and VisibleToBuyerGroups__c
- Use junction objects for proper relationships
- Current approach limits scalability
MEDIUM¶
- Multiple SOQL Queries (getInternationalMembershipPrice, getTodayContribution): Sequential queries
- Optimize with single query or caching
- Manual Aggregation Loops (getTotalAmountPacContribution): Use SOQL aggregate functions
- Hardcoded Values: Pricebook types, email addresses, magic strings
- Use constants or custom metadata
LOW¶
- Product Name Matching (getAccountCommunityProducts): Fragile matching by Name
- Use SKU or external Id for stability
- Missing LIMIT Clauses (getMembershipAutoRenewalCoupon, getPromotionTarget): Could throw exception
- Annual Frequency Logic (getScheduledOrders:852): Inconsistent calculation
Test Class Requirements¶
@IsTest
public class ProductControllerTest {
@TestSetup
static void setup() {
// Setup products
Product2 prod = TestDataFactory.getProduct(true);
prod.StockKeepingUnit = 'P-PAC-032';
prod.Family = 'PAC Contribution';
update prod;
// Setup pricebooks for international pricing
Pricebook2 tier1 = new Pricebook2(Name = 'Tier 1', Type__c = 'Tier 1 Countries', IsActive = true);
insert tier1;
// Setup account with buyer groups
Account acc = TestDataFactory.getAccountRecord(true);
acc.BuyerGroupIds__c = 'someId1,someId2';
update acc;
}
@IsTest
static void testGetMembershipProducts() {
Test.startTest();
List<Product2> products = ProductController.getMembershipProducts();
Test.stopTest();
Assert.isNotNull(products, 'Should return products');
}
@IsTest
static void testGetInternationalMembershipPrice() {
Product2 prod = [SELECT Id FROM Product2 LIMIT 1];
Test.startTest();
PricebookEntry pbe = ProductController.getInternationalMembershipPrice(prod.Id, 'US');
Test.stopTest();
// Assert based on country tier setup
}
@IsTest
static void testCheckBuyerGroupAccess() {
Product2 prod = [SELECT Id FROM Product2 LIMIT 1];
prod.VisibleToBuyerGroups__c = 'someId1';
update prod;
Account acc = [SELECT Id FROM Account LIMIT 1];
Test.startTest();
String result = ProductController.checkBuyerGroupAccess(prod.Id, acc.Id);
Test.stopTest();
Assert.areEqual('ACCESS_GRANTED', result, 'Should grant access');
}
}
Changes & History¶
| Date | Author | Description |
|---|---|---|
| 08/15/2024 | Ecaterina Popa | Initial implementation |
| 09/03/2024 | Ecaterina Popa | Updates |
| 11/06/2025 | Antoneac Victor | Community product features |
Documentation Status: ✅ Complete Code Review Status: 🚨 CRITICAL - Move hardcoded SKUs to metadata, add updateChargentOrders security Test Coverage: Test class needed LWC Integration: Product catalog, membership wizard, PAC contribution components International Pricing: Tier 1/2/3 country groups via custom metadata