Skip to content

Class Name: SubscriptionService

Last Updated: 2025-10-22 Source Code: https://github.com/AANP-IT/I2C.Salesforce.Metadata/blob/STAGING/force-app/main/default/classes/SubscriptionService.cls

API Name: SubscriptionService Type: Service Class Test Coverage: SubscriptionServiceTest.cls Author: Unknown Created: Unknown

Business Purpose

This service class implements AANP's subscription cancellation business logic, handling the complex relationships between member subscriptions, recurring payments, and membership status. It enforces critical business rules such as "cancelling any membership subscription cancels ALL subscriptions for that account" and manages integration with the Chargent payment processing system to stop recurring billing. This centralized service ensures consistent subscription lifecycle management across member portals, customer service tools, and automated processes while maintaining data integrity and payment system synchronization.

Class Overview

Scope and Sharing

  • Sharing Model: with sharing (respects record-level security)
  • Access Modifier: public
  • Interfaces Implemented: None

Key Responsibilities

  • Cancel individual subscriptions by ID
  • Cancel all subscriptions for an account (membership cancellation rule)
  • Stop Chargent recurring payments for PAC contributions
  • Update subscription status and auto-renewal flags
  • Maintain static collections for efficient bulk DML
  • Provide subscription status constants
  • Handle test-specific logic variations
  • Throw AuraHandledExceptions for LWC error handling

Public Properties

subscriptionsToUpdate

public static List<Subscription__c> subscriptionsToUpdate = new List<Subscription__c>();

Purpose: Static collection that accumulates subscription records to update. Enables efficient bulk DML operations by collecting multiple updates before single DML statement.

Usage: Populated by addUpdatedSubscription() helper method, updated in cancelSubscriptions() and cancelAllSubscriptions().

Risk: Static variable persists across transactions in same execution context - potential data leakage if not properly cleared.


chargentOrdersToUpdate

public static List<ChargentOrders__ChargentOrder__c> chargentOrdersToUpdate = new List<ChargentOrders__ChargentOrder__c>();

Purpose: Static collection for Chargent recurring orders that need payment status updated to 'Stopped'.

Usage: Populated when cancelling PAC Contribution subscriptions that have associated recurring payments.

Risk: Same static variable risk as subscriptionsToUpdate.


MEMBERSHIP

public static String MEMBERSHIP = 'Membership';

Purpose: Product family constant for membership products. Used to detect membership cancellations that trigger account-wide subscription cancellation.

Note: Line 26 has test-specific override: if(Test.isRunningTest()){ MEMBERSHIP = 'Memberships'; } - CRITICAL BUG RISK.


EXPIRED_STATUS

public static final String EXPIRED_STATUS = 'Expired';

Purpose: Constant for subscription status when cancelled.


ACTIVE_STATUS

public static final String ACTIVE_STATUS = 'Active';

Purpose: Constant for active subscription status (currently unused in code).


Public Methods

cancelSubscriptions

public static void cancelSubscriptions(final List<String> subscriptionIds)

Purpose: Cancels specified subscriptions by ID, applying business rules for membership and PAC contributions. Implements cascading cancellation logic where membership cancellation triggers account-wide subscription cancellation.

Parameters: - subscriptionIds (List): List of Subscription__c record IDs to cancel

Returns: void

Throws: - Does not explicitly throw (swallows query exceptions)

Usage Example:

// From LWC or Apex:
List<String> subsToCancel = new List<String>{'a0X5e000001ABC', 'a0X5e000001DEF'};
SubscriptionService.cancelSubscriptions(subsToCancel);

// Static collections automatically updated and DML performed

Business Logic:

  1. Validate Input:
    if(subscriptionIds == null || subscriptionIds.isEmpty()) { return; }
    
  2. Early return if no subscriptions provided

  3. Query Subscription Details:

    List<Subscription__c> getSelectedSubscriptions = [
        SELECT Id, Account__c, Product__r.Family, Renew_On__c,
               Chargent_Order__r.ChargentOrders__Next_Scheduled_Payment__c,
               Chargent_Order__c,
               Auto_Renew__c, Order_Product__c, Order_Product__r.Chargent_Recurring_Order__c
        FROM Subscription__c
        WHERE Id IN :subscriptionIds
    ];
    

  4. Retrieves subscription with product, Chargent, and order relationships
  5. No error handling if query fails

  6. Validate Query Results:

    if (getSelectedSubscriptions.isEmpty()) { return; }
    

  7. Extract Account ID:

    String accountId = getSelectedSubscriptions[0].Account__c;
    

  8. ASSUMPTION: All subscriptions belong to same account
  9. RISK: If subscriptions span multiple accounts, only first account ID used

  10. Process Each Subscription:

    for (Subscription__c subRecord : getSelectedSubscriptions) {
        if(Test.isRunningTest()){ MEMBERSHIP = 'Memberships'; }
        if (subRecord.Product__r != null && subRecord.Product__r.Family == MEMBERSHIP) {
            cancelAllSubscriptions(accountId);
            break;
        } else if (subRecord.Product__c != null && subRecord.Product__r.Family == 'PAC Contribution' && subRecord.Order_Product__r.Chargent_Recurring_Order__c != null) {
            if (subRecord.Order_Product__r.Chargent_Recurring_Order__c != null) {
                chargentOrdersToUpdate.add( new ChargentOrders__ChargentOrder__c ( Id = subRecord.Order_Product__r.Chargent_Recurring_Order__c, ChargentOrders__Payment_Status__c = 'Stopped'));
            }
            addUpdatedSubscription(subRecord.Id, false, EXPIRED_STATUS);
        } else {
            addUpdatedSubscription(subRecord.Id, false, EXPIRED_STATUS);
        }
    }
    

Business Rules Applied:

  • Rule 1 - Membership Cancellation: If ANY subscription has Product Family = 'Membership':

    • Call cancelAllSubscriptions() for the account
    • Break loop (don't process remaining subscriptions)
    • All subscriptions for account cancelled (not just requested ones)
  • Rule 2 - PAC Contribution Cancellation: If Product Family = 'PAC Contribution' with Chargent recurring order:

    • Stop Chargent recurring order (set Payment Status = 'Stopped')
    • Expire subscription (Status = 'Expired', Auto_Renew = false)
  • Rule 3 - Other Subscriptions:

    • Simply expire subscription (Status = 'Expired', Auto_Renew = false)
  • Perform DML Operations:

    if (!subscriptionsToUpdate.isEmpty()) {
        update subscriptionsToUpdate;
    }
    
    if (!chargentOrdersToUpdate.isEmpty()) {
        update chargentOrdersToUpdate;
    }
    

  • Updates subscriptions and Chargent orders
  • No error handling on DML operations

Critical Issues:

  1. Test-Specific Logic (Line 26):
    if(Test.isRunningTest()){ MEMBERSHIP = 'Memberships'; }
    
  2. CRITICAL BUG: Changes constant mid-execution in tests
  3. Production uses 'Membership', tests use 'Memberships'
  4. Creates production vs test discrepancy
  5. Could hide bugs or cause production failures

  6. Duplicate Null Check (Lines 30-31):

    } else if (subRecord.Product__c != null && subRecord.Product__r.Family == 'PAC Contribution' && subRecord.Order_Product__r.Chargent_Recurring_Order__c != null) {
        if (subRecord.Order_Product__r.Chargent_Recurring_Order__c != null) {
    

  7. Checks same condition twice
  8. Redundant inner if statement

  9. Account ID Assumption:

  10. Line 23: String accountId = getSelectedSubscriptions[0].Account__c;
  11. Assumes all subscriptions belong to same account
  12. If called with subscriptions from multiple accounts, only first account's subscriptions cancelled

cancelAllSubscriptions

public static void cancelAllSubscriptions(String accountId)

Purpose: Cancels ALL active subscriptions for an account. Called when any membership subscription is cancelled. Stops all PAC Contribution recurring payments and expires all subscriptions regardless of type.

Parameters: - accountId (String): Account__c ID for which to cancel all subscriptions

Returns: void

Throws: - AuraHandledException: Wraps any exceptions for LWC error handling

Usage Example:

// Typically called internally by cancelSubscriptions() when membership cancelled
// Can also be called directly:
SubscriptionService.cancelAllSubscriptions('001F000001ABC');

Business Logic:

  1. Clear Static Collections:
    subscriptionsToUpdate.clear();
    chargentOrdersToUpdate.clear();
    
  2. CRITICAL: Clears collections before processing
  3. ISSUE: But calling method (cancelSubscriptions) performs DML on these collections after return
  4. Creates transaction inconsistency

  5. Query All Active Subscriptions:

    List<Subscription__c> getAllActiveSubscriptions = SubscriptionSelector.getActiveSubscriptions(accountId);
    

  6. Delegates to SubscriptionSelector for query
  7. Gets all subscriptions with Status__c = 'Active'

  8. Process Each Subscription:

    for (Subscription__c subRecord : getAllActiveSubscriptions) {
        if (subRecord.Product__c != null && subRecord.Product__r.Family == 'PAC Contribution' && subRecord.Order_Product__r.Chargent_Recurring_Order__c != null) {
            chargentOrdersToUpdate.add( new ChargentOrders__ChargentOrder__c ( Id = subRecord.Order_Product__r.Chargent_Recurring_Order__c, ChargentOrders__Payment_Status__c = 'Stopped'));
        }
        addUpdatedSubscription(subRecord.Id, false, EXPIRED_STATUS);
    }
    

  9. Stops recurring payments for PAC Contributions
  10. Expires all subscriptions (Membership, PAC, and others)

  11. Error Handling:

    } catch (Exception e) {
        System.debug('[Service] cancelAllSubscriptions Exception: ' + e.getMessage());
        System.debug('[Service] Stack Trace: ' + e.getStackTraceString());
        throw new AuraHandledException('Error in cancelAllSubscriptions: ' + e.getMessage());
    }
    

  12. Logs exception details
  13. Throws AuraHandledException for LWC consumption
  14. NOTE: Does NOT perform DML - relies on calling method

Critical Issues:

  1. Collection Clearing Bug:
  2. Lines 51: Clears subscriptionsToUpdate and chargentOrdersToUpdate
  3. But cancelSubscriptions() (calling method) performs DML on these collections after method returns
  4. If cancelAllSubscriptions() called, calling method will perform DML on empty collections
  5. Subscriptions added in cancelAllSubscriptions() never updated

  6. No DML Performed:

  7. Method populates collections but doesn't perform updates
  8. Relies on calling method to perform DML
  9. Inconsistent with method name/purpose

  10. Transaction Management:

  11. No transaction control
  12. No rollback on partial failure
  13. Could have some subscriptions expired, others not

addUpdatedSubscription

public static void addUpdatedSubscription(String recordId, Boolean autoRenew, String status)

Purpose: Helper method that creates Subscription__c record for update and adds to static collection.

Parameters: - recordId (String): Subscription__c record ID - autoRenew (Boolean): New value for Auto_Renew__c - status (String): New value for Status__c

Returns: void

Logic:

subscriptionsToUpdate.add(
    new Subscription__c (
        Id = recordId,
        Auto_Renew__c = autoRenew,
        Status__c = status
    )
);

Usage: Called by cancelSubscriptions() and cancelAllSubscriptions() to accumulate updates.


Private/Helper Methods

None - All methods are public.

Could Be Enhanced With: - validateAccountSubscriptions(subscriptionIds) - Ensure all belong to same account - stopRecurringPayment(chargentOrderId) - Extract Chargent logic - expireSubscription(subscriptionId) - Extract expiration logic


Dependencies

Apex Classes

SubscriptionSelector - Purpose: Query active subscriptions for account - Method: getActiveSubscriptions(accountId) - Criticality: HIGH - Required for cancelAllSubscriptions()

Salesforce Objects

Subscription__c (Custom) - Fields updated: - Status__c (set to 'Expired') - Auto_Renew__c (set to false) - Fields read: - Id, Account__c, Product__c, Product__r.Family - Renew_On__c, Chargent_Order__c, Order_Product__c - Chargent_Order__r.ChargentOrders__Next_Scheduled_Payment__c - Order_Product__r.Chargent_Recurring_Order__c - Purpose: Core subscription records

Product2 (Standard) - Fields read: Family - Values: 'Membership', 'PAC Contribution' - Purpose: Determines cancellation behavior

ChargentOrders__ChargentOrder__c (Chargent Package) - Fields updated: ChargentOrders__Payment_Status__c (set to 'Stopped') - Purpose: Stop recurring billing

OrderItem (Standard, referenced as Order_Product__c) - Fields read: Chargent_Recurring_Order__c - Purpose: Link to Chargent recurring order

Custom Settings/Metadata

  • None - Could benefit from:
  • Subscription_Rules__mdt: Configure membership cancellation behavior
  • Product_Family__mdt: Configurable product family values

External Services

Chargent Payment Processing - Integration via ChargentOrders__ChargentOrder__c updates - Stops recurring payments when Status = 'Stopped'


Design Patterns

  • Service Layer Pattern: Encapsulates business logic
  • Static Collection Pattern: Accumulates updates for bulk DML
  • Cascading Delete Pattern: Membership cancellation triggers all subscriptions
  • Selector Pattern: Delegates queries to SubscriptionSelector
  • Exception Translation Pattern: Converts exceptions to AuraHandledException

Why These Patterns: - Service layer centralizes subscription cancellation logic - Static collections enable bulk DML efficiency - Cascading pattern enforces business rule - Selector pattern separates queries from business logic - Exception translation enables LWC error handling

Governor Limits Considerations

SOQL Queries: 2 (subscription details, active subscriptions) DML Operations: 2 (subscriptions update, Chargent orders update) CPU Time: Low (simple logic) Heap Size: Low (small collections)

Bulkification: Partial - Can cancel multiple subscriptions in one call - Static collections accumulate updates - Single DML for all subscriptions - RISK: Membership cancellation could cascade to 100+ subscriptions

Async Processing: No (synchronous)

Governor Limit Risks: - LOW: 2 SOQL queries well within limits - LOW: 2 DML operations well within limits - MEDIUM: Account with 100+ subscriptions could consume significant heap - MEDIUM: No query limits on active subscriptions query

Performance Considerations: - Called from member portals (user-facing) - Should execute quickly (<1 second) - Membership cancellation slower (queries + cancels all subscriptions)

Recommendations: 1. Add query limit to SubscriptionSelector.getActiveSubscriptions() 2. Consider async processing for accounts with many subscriptions 3. Monitor performance for membership cancellations

Error Handling

Strategy: Minimal error handling, relies on exception propagation

Logging: - System.debug in cancelAllSubscriptions() only - No logging in cancelSubscriptions() - Debug logs not visible in production

User Notifications: - AuraHandledException in cancelAllSubscriptions() for LWC - No exception thrown from cancelSubscriptions() - Silent failures possible

Validation: - Null/empty check on subscriptionIds - No validation that subscriptions belong to same account - No validation of subscription status - No validation of Chargent order existence

Rollback Behavior: - No transaction control - Standard Salesforce rollback on DML failure - All updates rolled back or none (atomic)

Recommended Improvements: 1. CRITICAL: Fix collection clearing in cancelAllSubscriptions() 2. HIGH: Add try-catch around DML operations 3. HIGH: Validate all subscriptions belong to same account 4. MEDIUM: Add logging for all cancellation events 5. MEDIUM: Return cancellation results to caller 6. LOW: Validate subscription status before cancelling

Security Considerations

Sharing Rules: RESPECTED - Uses 'with sharing' - Users can only cancel subscriptions they have access to - Appropriate for member self-service

Field-Level Security: RESPECTED - FLS enforced on queries and DML - Users must have edit access to subscription fields

CRUD Permissions: RESPECTED - Users must have edit permission on Subscription__c - Users must have edit permission on ChargentOrders__ChargentOrder__c

Input Validation: MINIMAL - No validation of subscription ownership - No validation of account access - No validation of cancellation eligibility

Security Risks: - MEDIUM: Users could cancel subscriptions for any account they can see - LOW: Chargent order updates bypass some validations - LOW: No audit trail of who cancelled subscription

Business Impact: - Member can cancel own subscriptions (intended) - Customer service can cancel any member's subscriptions (if access granted) - Membership cancellation is irreversible

Mitigation Recommendations: 1. Validate user has access to account before cancellation 2. Add audit logging (who, when, why) 3. Consider approval process for membership cancellations 4. Implement "cancel confirmation" period


Test Class

Test Class: SubscriptionServiceTest.cls Coverage: To be determined

Test Scenarios That Should Be Covered:

cancelSubscriptions(): - Cancel single non-membership subscription - Cancel multiple non-membership subscriptions - Cancel membership subscription (should cancel all for account) - Cancel PAC Contribution with recurring order - Cancel PAC Contribution without recurring order - Cancel mixed subscription types - Null subscriptionIds parameter - Empty subscriptionIds list - Invalid subscription IDs - Subscriptions from multiple accounts (validate handling)

cancelAllSubscriptions(): - Account with single subscription - Account with multiple subscriptions (membership, PAC, other) - Account with no active subscriptions - Account with PAC subscriptions having recurring orders - Exception handling (verify AuraHandledException thrown) - Invalid account ID

Business Rules: - Verify membership cancellation triggers cancelAllSubscriptions() - Verify all subscriptions expired (Status = 'Expired') - Verify Auto_Renew__c set to false - Verify Chargent orders set to 'Stopped' - Verify only active subscriptions affected

Static Collections: - Verify subscriptionsToUpdate populated correctly - Verify chargentOrdersToUpdate populated correctly - Verify DML performed on collections - Test collection clearing behavior

Test-Specific Logic: - Verify Test.isRunningTest() changes MEMBERSHIP constant - Test with both 'Membership' and 'Memberships' product families

Testing Challenges: - Mocking Chargent objects (managed package) - Test data setup for product families - Verifying static collection behavior - Testing cascading cancellation logic - Simulating AuraHandledException scenarios

Test Data Requirements: - Subscription__c records with various product families - Product2 records with Family = 'Membership', 'PAC Contribution' - ChargentOrders__ChargentOrder__c records - OrderItem records with Chargent relationships - Multiple accounts with subscriptions


Changes & History

  • Created: Unknown (check git history)
  • Author: Unknown
  • Purpose: Centralize subscription cancellation logic
  • Related to: Member portal, customer service tools

⚠️ Pre-Go-Live Concerns

CRITICAL - Fix Before Go-Live

  • TEST-SPECIFIC LOGIC IN PRODUCTION CODE: Line 26 changes MEMBERSHIP constant in tests. Production uses 'Membership', tests use 'Memberships'. Creates test vs production discrepancy. Remove this logic immediately.
  • COLLECTION CLEARING BUG: cancelAllSubscriptions() clears static collections (line 51), but calling method cancelSubscriptions() performs DML on cleared collections. Updates lost. Fix: Remove clear() calls or refactor transaction management.
  • ACCOUNT ID ASSUMPTION: Line 23 assumes all subscriptions belong to first subscription's account. If called with multi-account subscriptions, wrong behavior. Add validation.
  • NO ERROR HANDLING ON DML: DML operations have no try-catch. Failures surface as generic errors. Add error handling and logging.

HIGH - Address Soon After Go-Live

  • NO TRANSACTION MANAGEMENT: cancelAllSubscriptions() doesn't perform DML, relies on caller. Inconsistent design. Refactor for clear transaction boundaries.
  • DUPLICATE NULL CHECK: Lines 30-31 check same condition twice. Remove redundant inner if.
  • NO AUDIT LOGGING: No tracking of who cancelled what when. Add audit trail for compliance.
  • SILENT FAILURES: cancelSubscriptions() doesn't throw exceptions. Failures invisible to caller. Return success/failure status.

MEDIUM - Future Enhancement

  • HARDCODED PRODUCT FAMILIES: 'Membership', 'PAC Contribution' hardcoded. Add custom metadata configuration.
  • NO CANCELLATION REASON: No field to track why subscription cancelled. Add reason picklist.
  • NO UNDO CAPABILITY: Cancellation irreversible. Consider soft delete with restore option.
  • LIMITED STATUS VALUES: Only 'Expired' used. Consider 'Cancelled', 'Suspended' for different scenarios.
  • NO EFFECTIVE DATE: Cancellation immediate. Consider future-dated cancellations.

LOW - Monitor

  • STATIC VARIABLE PERSISTENCE: Static collections persist across transactions. Could cause data leakage. Clear at start of methods.
  • UNUSED CONSTANT: ACTIVE_STATUS declared but never used. Remove or implement.
  • METHOD VISIBILITY: All methods public. Consider making helpers private/protected.
  • CODE ORGANIZATION: Extract Chargent logic to separate method.

Maintenance Notes

Complexity: Medium (business rules, static collections, Chargent integration) Recommended Review Schedule: Quarterly, when subscription business rules change

Key Maintainer Notes:

⚠️ CRITICAL BUG - TEST LOGIC:

if(Test.isRunningTest()){ MEMBERSHIP = 'Memberships'; }
- Line 26: Changes constant mid-execution in tests - Production: Product Family must be 'Membership' - Tests: Product Family must be 'Memberships' - Impact: Tests don't validate production behavior - Fix: Remove this line, use correct product family in both

🐛 CRITICAL BUG - COLLECTION CLEARING:

// In cancelAllSubscriptions():
subscriptionsToUpdate.clear(); // Line 51
chargentOrdersToUpdate.clear();

// Method populates collections, but doesn't perform DML

// In cancelSubscriptions() (calling method):
if (!subscriptionsToUpdate.isEmpty()) {
    update subscriptionsToUpdate; // Updates empty list!
}
- Impact: Subscriptions cancelled via membership rule never actually updated - Fix: Remove clear() calls OR perform DML in cancelAllSubscriptions()

📋 Usage Patterns: - Called from LWC member portal for subscription self-service - Called from customer service tools - May be called from scheduled jobs - Typical: Cancel 1-3 subscriptions per call - Membership cancellation: Cancels all subscriptions (could be 10+)

🧪 Testing Requirements: - Test membership cancellation cascading rule - Test PAC Contribution recurring payment stop - Test static collection behavior - Test error scenarios (invalid IDs, query failures) - Verify DML actually performed - Test with real Chargent objects if possible

🔧 Configuration Dependencies: - Subscription__c custom object with fields - Product2.Family must include 'Membership', 'PAC Contribution' - ChargentOrders__ChargentOrder__c managed package - SubscriptionSelector class must exist - Chargent integration must be configured

⚠️ Gotchas and Warnings: - Test logic changes constant (line 26) - Collection clearing breaks transaction (line 51) - Membership cancellation cancels ALL subscriptions (business rule) - Assumes all subscriptions belong to same account - No validation of account ownership - No audit trail - No error handling on DML - Static collections persist across transactions

📅 When to Review This Class: - IMMEDIATELY: Fix test logic and collection clearing bugs - When subscription business rules change - When adding new product families - If Chargent integration changes - When implementing cancellation workflows - If member complaints about cancellation behavior

🛑 Emergency Deactivation:

// Option 1: Add custom metadata check
Subscription_Settings__mdt settings = Subscription_Settings__mdt.getInstance('Cancellation');
if (settings == null || !settings.Allow_Cancellation__c) {
    throw new AuraHandledException('Subscription cancellation is temporarily disabled');
}

// Option 2: Disable membership cascade
// Comment out cancelAllSubscriptions() call:
// if (subRecord.Product__r != null && subRecord.Product__r.Family == MEMBERSHIP) {
//     cancelAllSubscriptions(accountId);
//     break;
// }

// Option 3: Add validation
if (subscriptionIds.size() > 10) {
    throw new AuraHandledException('Cannot cancel more than 10 subscriptions at once');
}

🔍 Debugging Tips: - Enable debug logs for user executing cancellation - Check Subscription__c records: SELECT Id, Status__c, Auto_Renew__c FROM Subscription__c WHERE Account__c = '<account_id>' - Check Chargent orders: SELECT Id, ChargentOrders__Payment_Status__c FROM ChargentOrders__ChargentOrder__c WHERE Id IN ('<order_ids>') - Verify Product2.Family values: SELECT Id, Name, Family FROM Product2 WHERE Family IN ('Membership','PAC Contribution') - Check SubscriptionSelector query results - Add System.debug statements to track collection population

📊 Monitoring Checklist: - Daily: Cancellation request volume - Weekly: Membership cancellation rate (business metric) - Weekly: Failed cancellation attempts (errors) - Monthly: PAC Contribution stop success rate - Alert: Spike in cancellation errors - Alert: Chargent payment status update failures

🔗 Related Components: - SubscriptionSelector: Query layer - Member Portal LWC: User-facing cancellation UI - Subscription__c: Core subscription object - ChargentOrders__ChargentOrder__c: Payment processing - Product2: Product family classification - CanceledOrderLogic: May have related cancellation logic


Business Owner

Primary: Membership Operations / Subscription Management Secondary: Customer Service / Member Experience Stakeholders: Finance, Revenue Operations, Member Services, IT Operations