Skip to content

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

public static String resolveCommunityIdToWebstoreId(String communityId)

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

@AuraEnabled(cacheable=true)
public static List<Product2> 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

@AuraEnabled(cacheable=true)
public static List<Product2> getProductsByFamily(String productFamily)

Purpose: Queries products by Family field (lines 137-164).

Similar Pattern: FLS check, active/web-enabled filters, returns null on error.

getMembershipAutoRenewalCoupon

@AuraEnabled(cacheable=true)
public static Coupon 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:

  1. Check Metadata Access (lines 270-275):
    if(
        !Schema.sObjectType.Countries_Group_1__mdt.isAccessible() &&
        !Schema.sObjectType.Countries_Group_2__mdt.isAccessible() &&
        !Schema.sObjectType.Countries_Group_3__mdt.isAccessible()) {
            throw new AuraHandledException('You do not have access to the Country Groups metadata.');
        }
    
  2. Uses metadata types for country groupings

  3. 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');
            }
        }
    }
    

  4. Nested if-else to determine tier
  5. Queries metadata for each tier sequentially

  6. Get Price Entry (lines 293-297):

    if(countryGroupPricebook != null){
        price = ProductController.getPricebookEntry(productId, countryGroupPricebook.Id);
    } else {
        throw new AuraHandledException('The PriceBook could not be found.');
    }
    

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

@AuraEnabled
public static Product2 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

@AuraEnabled
public static Map<String, Product2> 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

@AuraEnabled
public static Decimal getTodayContribution(String personAccountId)

Purpose: Calculates today's PAC contributions for cash payments only (lines 437-494).

Complex Logic:

  1. Get PAC Product Ids (lines 443-451):
    Set<Id> pacProductIds = new Set<Id>();
    for (Product2 product : [ SELECT Id
                              FROM Product2
                              WHERE Family = 'PAC Contribution' OR
                              StockKeepingUnit LIKE 'P-PAC%'
    ]) {
        pacProductIds.add(product.Id);
    }
    
  2. Uses Family or SKU pattern match

  3. Filter Today's Activated Orders (lines 458-466):

    List<Order> getTodayActivatedOrders = [SELECT Id, Status, ActivatedDate
                                            FROM Order
                                            WHERE AccountId = :personAccountId
                                            AND Status = 'Activated'
                                            AND ActivatedDate = TODAY];
    

  4. Find Cash Payment Chargent Orders (lines 467-474):

    List<ChargentOrders__ChargentOrder__c> getTodayCreatedChargentOrders = [SELECT Id, Standard_Order__c, ChargentOrders__Payment_Method__c
                                                                            FROM ChargentOrders__ChargentOrder__c
                                                                            WHERE Standard_Order__c IN : orderIds
                                                                            AND ChargentOrders__Payment_Method__c = 'Cash'];
    

  5. Only includes cash payments (not credit card)

  6. Aggregate Today's Contribution (lines 477-486):

    AggregateResult result = [ SELECT SUM(TotalPrice) total
                               FROM OrderItem
                               WHERE Product2Id IN :pacProductIds
                               AND Order.Status = 'Activated'
                               AND Order.Manual_Order__c = true
                               AND Order.ActivatedDate = TODAY
                               AND Order.AccountId = :personAccountId
                               AND Order.Id IN :cashOrderIds
    
    ];
    

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

@AuraEnabled(cacheable=true)
public static String checkBuyerGroupAccess(Id productId, String accId)

Purpose: Determines if account has buyer group access to product (lines 545-610).

Business Logic:

  1. Guest Check (lines 547-549):

    if (UserInfo.getUserType() == 'Guest') {
        return 'LOGIN_REQUIRED';
    }
    

  2. Get Account Buyer Groups (lines 557-575):

    acc = [SELECT BuyerGroupIds__c FROM Account WHERE Id = :accId LIMIT 1];
    
    Set<Id> accountBuyerGroups = new Set<Id>();
    for (String bgId : acc.BuyerGroupIds__c.split(',')) {
        String trimmed = bgId.trim();
        if (!String.isBlank(trimmed)) {
            accountBuyerGroups.add(Id.valueOf(trimmed));
        }
    }
    

  3. BuyerGroupIds__c is comma-separated string of Ids

  4. Get Product Visible Buyer Groups (lines 582-601):

    Product2 product = [
        SELECT Id, VisibleToBuyerGroups__c
        FROM Product2
        WHERE Id = :productId
        LIMIT 1
    ];
    
    Set<Id> visibleBuyerGroups = new Set<Id>();
    for (String idStr : product.VisibleToBuyerGroups__c.split(',')) {
        if (!String.isBlank(idStr)) {
            visibleBuyerGroups.add((Id)idStr.trim());
        }
    }
    

  5. Compare Sets (lines 604-608):

    for (Id bg : accountBuyerGroups) {
        if (visibleBuyerGroups.contains(bg)) {
            return 'ACCESS_GRANTED';
        }
    }
    return 'ACCESS_DENIED-LAST';
    

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:

  1. 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;
    }
    

  2. 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;
    }
    

  3. Calculates remaining payments in current year
  4. 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(';');
}
- Higher_Logic_Communities__c is multi-select picklist - Semicolon-separated values

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
];
- Matches products by Name (not SKU or external Id)

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

  1. LWC Controller Pattern: @AuraEnabled methods for components
  2. Caching Pattern: communityIdToWebStoreIdCache for performance
  3. FLS Pattern: Schema.sObjectType checks for object access
  4. DTO Pattern: ScheduledOrderInfo wrapper class
  5. Metadata-Driven: Country tier pricing via custom metadata
  6. 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