diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0631a94 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# Commerce Extensibility Repository + +Sample implementations for extending Salesforce Commerce services (pricing, buyer groups, tax, shipping, checkout, etc.). + +## Repository Structure + +``` +commerce/ +├── domain/ # Business logic extensions (pricing, tax, buyer groups, etc.) +└── endpoint/ # API extensions (cart, search, account) +``` + +--- + +## Apex v67.0 Migration + +Reference: [Apex Release Notes - Summer '26](https://help.salesforce.com/s/articleView?id=release-notes.rn_apex.htm&release=262&type=5) + +When a user asks to migrate files for Apex v67.0, follow this process: + +#### Step 1: Analyze + +Scan `.cls` files and identify issues based on the v67.0 Secure by Default changes: + +**Sharing Keywords** - Classes without a sharing keyword now default to `WITH SHARING`: +```apex +// Explicitly declare the sharing mode you need +public without sharing class MyExtension { } +public with sharing class MyExtension { } +public inherited sharing class MyExtension { } +``` + +**Query Mode** - Queries now run as `USER_MODE` by default: +```apex +// Add WITH SYSTEM_MODE only if you need to bypass user permissions +[SELECT Id FROM Account WHERE Id = :id WITH SYSTEM_MODE] +``` + +**DML Access Level** - DML operations now run as `USER_MODE` by default: +```apex +// Add SYSTEM_MODE only if you need to bypass user permissions +Database.insert(records, AccessLevel.SYSTEM_MODE); +insert as system records; +``` + +**Deprecated Syntax** - `WITH SECURITY_ENFORCED` is deprecated: +```apex +// ❌ Deprecated +[SELECT Name FROM Account WITH SECURITY_ENFORCED] + +// ✅ Use WITH USER_MODE +[SELECT Name FROM Account WITH USER_MODE] +``` + +#### Step 2: Show Diff to User + +Present each change clearly with file name, line numbers, what will change, and why. + +If user asks, show detailed before/after diff for each change. + +#### Step 3: Get Approval + +``` +Should I apply these X changes across Y files? (yes/no) + +Reply 'yes' to proceed, or ask me to show details for any file. +``` + +**Wait for explicit "yes" before proceeding.** + +#### Step 4: Apply Changes + +1. Update code with required changes +2. Keep API version unchanged (backward compatibility) +3. Preserve existing formatting + +#### Step 5: Report + +``` +✅ Migration complete! + +Updated: +- File1.cls (4 queries, docs) +- File2.cls (docs only) + +Next steps: +1. Review changes +2. Run tests +3. Commit when ready +``` + diff --git a/README.md b/README.md index a298f07..47068de 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,11 @@ This repository contains a reference implementation of the Commerce Extensibilit - Shipping Calculator - Tax Calculator - Tax Service -- [Domain Extension for Checkout](#domain-extension-for-checkout) - - Checkout Create Order +- [Domain Extensions for Checkout](#domain-extensions-for-checkout) + - CreateOrder Service + - SplitShipment Service +- [Domain Extensions for Buyer Group](#domain-extensions-for-buyer-group) + - Buyer Group Extensibility Service Each set of sample code includes: an Apex class, a test class, and any necessary resource files. @@ -37,7 +40,7 @@ The sample code for Shipping Calculator includes an Apex class (in `ShippingCalc ### Tax Calculator -The sample code for Tax Calculator includes an Apex class (in `TaxCalculatorSample.apxc`) that calls an external service to retrieve tax information and then save those taxes in `CartTaxes` in `CartItems` and `CartItemAdjustments`. +The sample code for Tax Calculator includes an Apex class (in `TaxCalculatorSample.apxc`) that calls an external service to retrieve tax information and then save those taxes in `CartTaxes` in `CartItems`. ### Tax Service @@ -54,15 +57,32 @@ All error cases are propagated to the admin as CommerceDiagnosticEvents (see [Co All reference implementations include examples of how to propagate an error to the user. -## Domain Extension for Checkout -### Checkout Create Order +## Domain Extensions for Checkout -The sample code for [Checkout Create Order](https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/CheckoutCreateOrder.html) extension point includes an Apex class (see [CreateOrderSample.cls](commerce/domain/checkout/order/createOrder/classes/CreateOrderSample.cls) that provides an example of how to work with the [OrderGraph](https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/OrderGraph.html). +### CreateOrder Service -A unit test (see [CreateOrderSampleUnitTest.cls](commerce/domain/checkout/order/createOrder/classes/CreateOrderSampleUnitTest.cls))) that follows this approach for [Mocking the Base Apex Class in Tests](https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/mock-the-base-apex-class.html). +The sample code for the [CreateOrder Service](https://developer.salesforce.com/docs/commerce/salesforce-commerce/references/comm-apex-reference/CheckoutCreateOrder.html) extension point includes an Apex class (see [CreateOrderSample.cls](commerce/domain/checkout/order/createOrder/classes/CreateOrderSample.cls)) that provides an example of how to work with the [OrderGraph](https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/OrderGraph.html). + +A unit test (see [CreateOrderSampleUnitTest.cls](commerce/domain/checkout/order/createOrder/classes/CreateOrderSampleUnitTest.cls)) that follows this approach for [Mocking the Base Apex Class in Tests](https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/mock-the-base-apex-class.html). Also, there is an "integration" test (see [CreateOrderSampleIntegrationTest.cls](commerce/domain/checkout/order/createOrder/classes/CreateOrderSampleIntegrationTest.cls)) that verifies implementation of the CreateOrder extension that calls real default extension point behavior. This testing approach can be used in addition to unit test, it has wider test coverage, but it is less isolated than unit test presented in the example below. +### SplitShipment Service + +The sample code for the [SplitShipment Service](https://developer.salesforce.com/docs/commerce/salesforce-commerce/references/comm-apex-reference/SplitShipmentService.html) extension point includes an Apex class (see [SplitShipmentSample.cls](commerce/domain/shipping/splitshipment/SplitShipmentSample.cls)) that creates cart delivery groups by conveyable and non-conveyable products. + +Another example (see [SplitShipmentCallsSuper.cls](commerce/domain/shipping/splitshipment/SplitShipmentCallsSuper.cls)) calls the default implementation. + +Also, there is a unit test (see [SplitShipmentUnitTest.cls](commerce/domain/shipping/splitshipment/SplitShipmentUnitTest.cls)) that follows this approach for [Mocking the Base Apex Class in Tests](https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/mock-the-base-apex-class.html). + +## Domain Extensions for Buyer Group + +### Buyer Group Extensibility Service + +The sample code for Buyer Group Extensibility Service includes the following : +- An Apex class (in `BuyerGroupEvaluationServiceSample.apxc`) that uses active postal codes to retrieve buyer groups for logged in and guest users. +- An Org Platform Cache (in `BuyerGroup.cachePartition`) to support low latency in buyer group retrieval. +- Supported Custom objects for storing and associating buyer groups to users via postal codes.(in `PostalCode__c`, `Active_PostalCode__c` and `Postal_Code_Buyer_Group__c`) ## Deployment diff --git a/commerce/domain/buyergroup/service/cachePartitions/BuyerGroup.cachePartition-meta.xml b/commerce/domain/buyergroup/service/cachePartitions/BuyerGroup.cachePartition-meta.xml new file mode 100644 index 0000000..d51a751 --- /dev/null +++ b/commerce/domain/buyergroup/service/cachePartitions/BuyerGroup.cachePartition-meta.xml @@ -0,0 +1,19 @@ + + + true + BuyerGroup + + 0 + 0 + 0 + 0 + Session + + + 10 + 0 + 0 + 0 + Organization + + diff --git a/commerce/domain/buyergroup/service/classes/BuyerGroupEvaluationServiceSample.cls b/commerce/domain/buyergroup/service/classes/BuyerGroupEvaluationServiceSample.cls new file mode 100644 index 0000000..15defd0 --- /dev/null +++ b/commerce/domain/buyergroup/service/classes/BuyerGroupEvaluationServiceSample.cls @@ -0,0 +1,101 @@ +/** + * BuyerGroupEvaluationServiceSample is a sample implementation of the BuyerGroupEvaluationService used to determine buyer group IDs for a user (guest or logged-in). + * The use case for this sample implementation is as follows: + * - Out-of-the-box buyer groups based on account, market, and data cloud segment should be returned for both logged-in and guest users. + * - In addition, if the user is a guest or logged-in, then the buyer groups associated with the active postal code stored using deviceId should also be returned. + * - For logged-in users, the buyer groups associated with the BillingPostalCode and ShippingPostalCode of the Account linked to the user are also returned. + * + * Data model changes to support the sample code are as follows: + * - PostalCode__c: Stores the supported postal codes in the PostalCode field. + * - Active_PostalCode__c: Stores the association between deviceId (Guest UUID cookie value) and PostalCode__c. + * This can typically be populated via a custom LWC component where a customer chooses a postal code from a list of available postal codes. + * - Postal_Code_Buyer_Group__c: A junction entity that stores a static mapping between PostalCode__c and the buyer groups associated with it. + * + * Important considerations in the code below: + * - Buyer group responses should be cached using Org Cache to support low latency. + * - No more than MAX_BUYER_GROUPS should be returned by the code. + * + * SECURITY CONSIDERATIONS (Secure by Default - Apex v67.0+): + * + * Starting with Apex v67.0 (Summer '26), all classes must explicitly declare sharing mode and + * all queries must specify WITH SYSTEM_MODE or WITH USER_MODE. + * + * This sample uses: + * - WITHOUT SHARING: Buyer group evaluation needs to work for guest users who have no object/field + * permissions. Using "with sharing" would prevent guest users from accessing buyer group data. + * + * - WITH SYSTEM_MODE for all queries: Guest users need to retrieve buyer group associations, postal + * codes, and account data. WITH USER_MODE would cause permission errors for guest users. + * + * Note: WITH SYSTEM_MODE is backward compatible (ignored in v64-v66 where it was already the default). + * + * For more information on Apex Secure by Default, see: + * https://help.salesforce.com/s/articleView?id=release-notes.rn_apex.htm&release=262&type=5 + */ +public without sharing class BuyerGroupEvaluationServiceSample extends commercebuygrp.BuyerGroupEvaluationService { + + private static Integer MAX_BUYER_GROUPS = 30; + + public override commercebuygrp.BuyerGroupResponse getBuyerGroupIds(commercebuygrp.BuyerGroupRequest request) { + String currentUserId = UserInfo.getUserId(); // Gets the current user ID; could be a logged-in or guest user + String webstoreId = request.getStoreId(); // Gets the webstore record ID + String accountId = request.getAccountId(); // Gets the account ID of the user + String siteId = ((String) [SELECT SiteId FROM WebstoreNetwork WHERE WebstoreId = :webstoreId WITH SYSTEM_MODE][0].get('SiteId')).substring(0, 15); // Gets the network site ID + Map requestParameters = request.getRequestContextParameters(); + Boolean isGuestUser = (Boolean) requestParameters.get('isGuestUser'); + String guestUUIDKey = 'guest_uuid_essential_' + siteId; // Gets the guest UUID cookie key for the current webstore + String deviceId = (String) requestParameters.get(guestUUIDKey); // Gets the guest UUID cookie value for the current user and webstore + + String cachePartition = 'local.BuyerGroup'; + Cache.OrgPartition orgPartition = Cache.Org.getPartition(cachePartition); // Gets the buyer group org cache partition + + // Cache key must be alphanumeric; converting currentUserId and deviceId to a hashed key + String cacheKey = EncodingUtil.convertToHex(Crypto.generateDigest('MD5', Blob.valueOf(isGuestUser ? deviceId : currentUserId))); + if (orgPartition.contains(cacheKey)) { + // Cache hit — return cached buyer groups + return new commercebuygrp.BuyerGroupResponse((Set) orgPartition.get(cacheKey)); + } + + // Getting default out-of-the-box buyer groups based on existing logic: account, market, and data cloud segment-based buyer groups + commercebuygrp.BuyerGroupResponse defaultBuyerGroupResponse = super.getBuyerGroupIds(request); + Set buyerGroupIds = new Set(defaultBuyerGroupResponse.getBuyerGroupIds()); + + Set activePostalCodes = new Set(); + + // If the user is logged in, get the BillingPostalCode and ShippingPostalCode + if (!isGuestUser) { + SObject currentAccount = [SELECT BillingPostalCode, ShippingPostalCode FROM Account WHERE Id = :accountId WITH SYSTEM_MODE][0]; + String billingPostalCode = (String) currentAccount.get('BillingPostalCode'); + String shippingPostalCode = (String) currentAccount.get('ShippingPostalCode'); + if (billingPostalCode != null) { + activePostalCodes.add(billingPostalCode); + } + if (shippingPostalCode != null) { + activePostalCodes.add(shippingPostalCode); + } + } + + // Get active postal codes for both logged-in and guest users based on their device ID + for (Active_PostalCode__c activePostalCode : [SELECT PostalCode__r.PostalCode__c FROM Active_PostalCode__c WHERE DeviceId__c = :deviceId WITH SYSTEM_MODE]) { + if (activePostalCode.PostalCode__c != null) { + activePostalCodes.add(activePostalCode.PostalCode__r.PostalCode__c); + } + } + + // Get the buyer groups associated with the postal codes + for (Postal_Code_Buyer_Group__c buyerGroup : [SELECT Buyer_Group__c FROM Postal_Code_Buyer_Group__c WHERE PostalCode__r.PostalCode__c IN :activePostalCodes WITH SYSTEM_MODE]) { + buyerGroupIds.add(buyerGroup.Buyer_Group__c); + } + + // Buyer group extensibility supports only up to MAX_BUYER_GROUPS buyer groups + if (buyerGroupIds.size() > MAX_BUYER_GROUPS) { + commercebuygrp.BuyerGroupResponse response = new commercebuygrp.BuyerGroupResponse(); + String errorMessage = 'More than ' + MAX_BUYER_GROUPS + ' buyer groups retrieved for the user. Contact Store Administrator.'; + response.setError(errorMessage, errorMessage); + return response; + } + + orgPartition.put(cacheKey, buyerGroupIds); + return new commercebuygrp.BuyerGroupResponse(buyerGroupIds); + } +} \ No newline at end of file diff --git a/commerce/domain/buyergroup/service/classes/BuyerGroupEvaluationServiceSample.cls-meta.xml b/commerce/domain/buyergroup/service/classes/BuyerGroupEvaluationServiceSample.cls-meta.xml new file mode 100644 index 0000000..1e7de94 --- /dev/null +++ b/commerce/domain/buyergroup/service/classes/BuyerGroupEvaluationServiceSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + Active + diff --git a/commerce/domain/buyergroup/service/classes/BuyerGroupShareableURLSample.cls b/commerce/domain/buyergroup/service/classes/BuyerGroupShareableURLSample.cls new file mode 100644 index 0000000..bba216e --- /dev/null +++ b/commerce/domain/buyergroup/service/classes/BuyerGroupShareableURLSample.cls @@ -0,0 +1,77 @@ +/** + * BuyerGroupShareableURLSample + * + * This class is a sample implementation of the {@code commercebuygrp.BuyerGroupEvaluationService}. + * It demonstrates how to extend the default buyer group evaluation logic to include custom criteria + * based on request context parameters. In this case, the custom criterion is the user's region, which + * is passed as a URL parameter (e.g., in a shareable URL for the storefront). + * + * Use Case: + * - Retrieve default buyer groups based on standard rules (account, market, and data cloud segments). + * - Additionally, include buyer groups associated with the user's region (if provided via request context). + * + * Notes: + * - This example demonstrates extensibility for shareable URLs in the commerce storefront. + * - To use this, the region parameter must be explicitly set in the request context via URL. + * - When implementing real logic, replace the placeholder region-based filtering with actual mapping logic. + * + * Limitations: + * - Maximum allowed buyer groups is controlled by {@link #MAX_BUYER_GROUPS} (currently set to 30). + * - If the number exceeds this limit, an error response is returned instead of the list. + * + * SECURITY CONSIDERATIONS (Secure by Default - Apex v67.0+): + * + * Starting with Apex v67.0 (Summer '26), all classes must explicitly declare sharing mode. + * + * This sample uses: + * - WITHOUT SHARING: Buyer group evaluation needs to work for guest users who have no permissions. + * The base implementation (super.getBuyerGroupIds) already handles data access with system-level + * permissions, so this class inherits that security model. + * + * For more information on Apex Secure by Default, see: + * https://help.salesforce.com/s/articleView?id=release-notes.rn_apex.htm&release=262&type=5 + */ +public without sharing class BuyerGroupShareableURLSample extends commercebuygrp.BuyerGroupEvaluationService { + + /** Maximum number of buyer groups that can be associated with a single user request. */ + private static final Integer MAX_BUYER_GROUPS = 30; + + /** + * Retrieves buyer group IDs for a user, including: + * - Default buyer groups (account, market, data cloud segments). + * - Region-based buyer groups (if region parameter is provided). + * + * @param request The {@link commercebuygrp.BuyerGroupRequest} containing context parameters. + * @return A {@link commercebuygrp.BuyerGroupResponse} with buyer group IDs or an error if the limit is exceeded. + */ + public override commercebuygrp.BuyerGroupResponse getBuyerGroupIds(commercebuygrp.BuyerGroupRequest request) { + // Extract context parameters from the incoming request + Map requestParameters = request.getRequestContextParameters(); + + // Retrieve the custom region parameter passed via shareable URL (e.g., ®ion=asia) + String region = (String) requestParameters.get('region'); + + // Get default out-of-the-box buyer groups using base implementation + commercebuygrp.BuyerGroupResponse defaultBuyerGroupResponse = super.getBuyerGroupIds(request); + Set buyerGroupIds = new Set(defaultBuyerGroupResponse.getBuyerGroupIds()); + + // If region is provided and equals "asia", add region-specific buyer groups + if (region != null && region.equalsIgnoreCase('asia')) { + // Add logic to fetch additional buyer groups for Asia region. + // Example: buyerGroupIds.addAll(getRegionSpecificGroups(region)); + } + + // Enforce maximum limit for buyer groups + if (buyerGroupIds.size() > MAX_BUYER_GROUPS) { + // Create error response when limit exceeds + commercebuygrp.BuyerGroupResponse response = new commercebuygrp.BuyerGroupResponse(); + String errorMessage = 'More than ' + MAX_BUYER_GROUPS + ' buyer groups retrieved for the user. Contact Store Administrator.'; + response.setError(errorMessage, errorMessage); + return response; + } + + + // Return final response with allowed buyer group IDs + return new commercebuygrp.BuyerGroupResponse(buyerGroupIds); + } +} diff --git a/commerce/domain/buyergroup/service/classes/BuyerGroupShareableURLSample.cls-meta.xml b/commerce/domain/buyergroup/service/classes/BuyerGroupShareableURLSample.cls-meta.xml new file mode 100644 index 0000000..2b8285e --- /dev/null +++ b/commerce/domain/buyergroup/service/classes/BuyerGroupShareableURLSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + \ No newline at end of file diff --git a/commerce/domain/buyergroup/service/objects/Active_PostalCode__c/Active_PostalCode__c.object-meta.xml b/commerce/domain/buyergroup/service/objects/Active_PostalCode__c/Active_PostalCode__c.object-meta.xml new file mode 100644 index 0000000..18c33fd --- /dev/null +++ b/commerce/domain/buyergroup/service/objects/Active_PostalCode__c/Active_PostalCode__c.object-meta.xml @@ -0,0 +1,166 @@ + + + + Accept + Default + + + Accept + Large + Default + + + Accept + Small + Default + + + CancelEdit + Default + + + CancelEdit + Large + Default + + + CancelEdit + Small + Default + + + Clone + Default + + + Clone + Large + Default + + + Clone + Small + Default + + + Delete + Default + + + Delete + Large + Default + + + Delete + Small + Default + + + Edit + Default + + + Edit + Large + Default + + + Edit + Small + Default + + + List + Default + + + List + Large + Default + + + List + Small + Default + + + New + Default + + + New + Large + Default + + + New + Small + Default + + + SaveEdit + Default + + + SaveEdit + Large + Default + + + SaveEdit + Small + Default + + + Tab + Default + + + Tab + Large + Default + + + Tab + Small + Default + + + View + Default + + + View + Large + Default + + + View + Small + Default + + false + SYSTEM + Deployed + false + true + false + false + false + false + false + true + true + Private + + + APC-{00000} + + AutoNumber + + Active Postal Codes + + ReadWrite + Public + diff --git a/commerce/domain/buyergroup/service/objects/Active_PostalCode__c/fields/DeviceId__c.field-meta.xml b/commerce/domain/buyergroup/service/objects/Active_PostalCode__c/fields/DeviceId__c.field-meta.xml new file mode 100644 index 0000000..e109448 --- /dev/null +++ b/commerce/domain/buyergroup/service/objects/Active_PostalCode__c/fields/DeviceId__c.field-meta.xml @@ -0,0 +1,11 @@ + + + DeviceId__c + false + + 40 + true + false + Text + false + diff --git a/commerce/domain/buyergroup/service/objects/Active_PostalCode__c/fields/PostalCode__c.field-meta.xml b/commerce/domain/buyergroup/service/objects/Active_PostalCode__c/fields/PostalCode__c.field-meta.xml new file mode 100644 index 0000000..cb295eb --- /dev/null +++ b/commerce/domain/buyergroup/service/objects/Active_PostalCode__c/fields/PostalCode__c.field-meta.xml @@ -0,0 +1,12 @@ + + + PostalCode__c + Restrict + + PostalCode__c + Active Postal Codes + Active_Postal_Codes + true + false + Lookup + diff --git a/commerce/domain/buyergroup/service/objects/PostalCode__c/PostalCode__c.object-meta.xml b/commerce/domain/buyergroup/service/objects/PostalCode__c/PostalCode__c.object-meta.xml new file mode 100644 index 0000000..59a6563 --- /dev/null +++ b/commerce/domain/buyergroup/service/objects/PostalCode__c/PostalCode__c.object-meta.xml @@ -0,0 +1,166 @@ + + + + Accept + Default + + + Accept + Large + Default + + + Accept + Small + Default + + + CancelEdit + Default + + + CancelEdit + Large + Default + + + CancelEdit + Small + Default + + + Clone + Default + + + Clone + Large + Default + + + Clone + Small + Default + + + Delete + Default + + + Delete + Large + Default + + + Delete + Small + Default + + + Edit + Default + + + Edit + Large + Default + + + Edit + Small + Default + + + List + Default + + + List + Large + Default + + + List + Small + Default + + + New + Default + + + New + Large + Default + + + New + Small + Default + + + SaveEdit + Default + + + SaveEdit + Large + Default + + + SaveEdit + Small + Default + + + Tab + Default + + + Tab + Large + Default + + + Tab + Small + Default + + + View + Default + + + View + Large + Default + + + View + Small + Default + + false + SYSTEM + Deployed + false + true + false + false + false + false + true + true + true + Private + + + POS-{0000} + + AutoNumber + + Postal Codes + + ReadWrite + Public + diff --git a/commerce/domain/buyergroup/service/objects/PostalCode__c/fields/PostalCode__c.field-meta.xml b/commerce/domain/buyergroup/service/objects/PostalCode__c/fields/PostalCode__c.field-meta.xml new file mode 100644 index 0000000..526c54d --- /dev/null +++ b/commerce/domain/buyergroup/service/objects/PostalCode__c/fields/PostalCode__c.field-meta.xml @@ -0,0 +1,12 @@ + + + PostalCode__c + false + false + + 8 + true + false + Text + true + diff --git a/commerce/domain/buyergroup/service/objects/Postal_Code_Buyer_Group__c/Postal_Code_Buyer_Group__c.object-meta.xml b/commerce/domain/buyergroup/service/objects/Postal_Code_Buyer_Group__c/Postal_Code_Buyer_Group__c.object-meta.xml new file mode 100644 index 0000000..c6dd113 --- /dev/null +++ b/commerce/domain/buyergroup/service/objects/Postal_Code_Buyer_Group__c/Postal_Code_Buyer_Group__c.object-meta.xml @@ -0,0 +1,166 @@ + + + + Accept + Default + + + Accept + Large + Default + + + Accept + Small + Default + + + CancelEdit + Default + + + CancelEdit + Large + Default + + + CancelEdit + Small + Default + + + Clone + Default + + + Clone + Large + Default + + + Clone + Small + Default + + + Delete + Default + + + Delete + Large + Default + + + Delete + Small + Default + + + Edit + Default + + + Edit + Large + Default + + + Edit + Small + Default + + + List + Default + + + List + Large + Default + + + List + Small + Default + + + New + Default + + + New + Large + Default + + + New + Small + Default + + + SaveEdit + Default + + + SaveEdit + Large + Default + + + SaveEdit + Small + Default + + + Tab + Default + + + Tab + Large + Default + + + Tab + Small + Default + + + View + Default + + + View + Large + Default + + + View + Small + Default + + false + SYSTEM + Deployed + false + true + false + false + false + false + false + true + true + Private + + + PCBG-{00000} + + AutoNumber + + Postal Code Buyer Groups + + ReadWrite + Public + diff --git a/commerce/domain/buyergroup/service/objects/Postal_Code_Buyer_Group__c/fields/Buyer_Group__c.field-meta.xml b/commerce/domain/buyergroup/service/objects/Postal_Code_Buyer_Group__c/fields/Buyer_Group__c.field-meta.xml new file mode 100644 index 0000000..6c0ca57 --- /dev/null +++ b/commerce/domain/buyergroup/service/objects/Postal_Code_Buyer_Group__c/fields/Buyer_Group__c.field-meta.xml @@ -0,0 +1,12 @@ + + + Buyer_Group__c + Restrict + + BuyerGroup + Postal Code Buyer Groups + Postal_Code_Buyer_Groups + true + false + Lookup + diff --git a/commerce/domain/buyergroup/service/objects/Postal_Code_Buyer_Group__c/fields/PostalCode__c.field-meta.xml b/commerce/domain/buyergroup/service/objects/Postal_Code_Buyer_Group__c/fields/PostalCode__c.field-meta.xml new file mode 100644 index 0000000..67e1904 --- /dev/null +++ b/commerce/domain/buyergroup/service/objects/Postal_Code_Buyer_Group__c/fields/PostalCode__c.field-meta.xml @@ -0,0 +1,12 @@ + + + PostalCode__c + Restrict + + PostalCode__c + Postal Code Buyer Groups + Postal_Code_Buyer_Groups + true + false + Lookup + diff --git a/commerce/domain/buyergroup/service/package.xml b/commerce/domain/buyergroup/service/package.xml new file mode 100644 index 0000000..d7908ee --- /dev/null +++ b/commerce/domain/buyergroup/service/package.xml @@ -0,0 +1,18 @@ + + + + BuyerGroupEvaluationServiceSample + ApexClass + + + BuyerGroup + PlatformCachePartition + + + Active_PostalCode__c + PostalCode__c + Postal_Code_Buyer_Group__c + CustomObject + + 64.0 + \ No newline at end of file diff --git a/commerce/domain/checkout/order/createOrder/classes/CreateOrderSample.cls b/commerce/domain/checkout/order/createOrder/classes/CreateOrderSample.cls index affd5a7..f58a40f 100644 --- a/commerce/domain/checkout/order/createOrder/classes/CreateOrderSample.cls +++ b/commerce/domain/checkout/order/createOrder/classes/CreateOrderSample.cls @@ -13,7 +13,9 @@ public virtual class CreateOrderSample extends CartExtension.CheckoutCreateOrder List orderItems = orderGraph.getOrderItems(); Decimal roundedPrice = 0.0; for (OrderItem orderItem : orderItems) { - roundedPrice += orderItem.UnitPrice.round(System.RoundingMode.CEILING); + if(orderItem.UnitPrice != null) { + roundedPrice += orderItem.UnitPrice.round(System.RoundingMode.CEILING); + } } order.Description += roundedPrice; return response; diff --git a/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSample.cls b/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSample.cls new file mode 100644 index 0000000..f41fb3b --- /dev/null +++ b/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSample.cls @@ -0,0 +1,61 @@ +/** + * @description This is a sample implementation of Place Order Action Extension for prepare and submit actions. + */ +public virtual with sharing class PlaceOrderActionExtensionSample extends ConnectApi.BaseEndpointExtension { + + // Restricted city constant + private static final String RESTRICTED_CITY = 'New York'; + + public override ConnectApi.EndpointExtensionRequest beforePost(ConnectApi.EndpointExtensionRequest request) { + if (request == null) { + return null; + } + + // Validation check: Ensure we do not ship to restricted cities + // If the delivery DeliverToCity is a restricted city, block the order + CartDeliveryGroup[] city = [SELECT DeliverToCity FROM CartDeliveryGroup LIMIT 1]; + if (city.size() < 1) { + throw new IllegalArgumentException('No delivery group found.'); + } + + // Check specific city - cannot ship to a restricted city + if (city.get(0).toString() == RESTRICTED_CITY) { + throw new IllegalArgumentException('We cannot ship to ' + RESTRICTED_CITY + '.'); + } + return request; + } + + public override ConnectApi.EndpointExtensionResponse afterPost(ConnectApi.EndpointExtensionResponse response, + ConnectApi.EndpointExtensionRequest request) { + if (response == null) { + return null; + } + + // Example post-hook logic + ConnectApi.CheckoutOrderActionCollectionRepresentation resp = + (ConnectApi.CheckoutOrderActionCollectionRepresentation) response.getResponseObject(); + List actions = resp.getActions(); + + if (actions != null && actions.size() < 1) { + throw new IllegalArgumentException('No actions found.'); + } + + String actionName = actions.get(0).getAction(); + + if (actionName == 'prepare') { + // Validation check to ensure PaymentGateway status is not 'Restricted'. + PaymentGateway[] status = [SELECT Status FROM PaymentGateway LIMIT 1]; + if (status.get(0).toString() == 'Restricted') { + throw new IllegalArgumentException('Payment Gateway status is Restricted.'); + } + } else if (actionName == 'submit') { + // Validation check to ensure the order reference number is not null. + String orderRef = actions.get(0).getOrderReferenceNumber(); + if (orderRef == null) { + throw new IllegalArgumentException('Order Reference Number is null.'); + } + } + + return response; + } +} \ No newline at end of file diff --git a/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSample.cls-meta.xml b/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSample.cls-meta.xml new file mode 100644 index 0000000..82775b9 --- /dev/null +++ b/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSampleTest.cls b/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSampleTest.cls new file mode 100644 index 0000000..a63f0f4 --- /dev/null +++ b/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSampleTest.cls @@ -0,0 +1,26 @@ +/** +* @description Example of a test class for Place Order Action Extension for the prepare and submit actions. +*/ +@isTest +private class PlaceOrderActionExtensionSampleTest { + + // Test the workflow: beforePost followed by afterPost + @isTest + static void testWorkflow() { + PlaceOrderActionExtensionSample extension = new PlaceOrderActionExtensionSample(); + + ConnectApi.EndpointExtensionRequest processedRequest = extension.beforePost(null); + ConnectApi.EndpointExtensionResponse processedResponse = extension.afterPost(null, null); + + System.assertEquals(null, processedRequest, 'beforePost should return null when input is null'); + System.assertEquals(null, processedResponse, 'afterPost should return null when input is null'); + } + + // Test that the extension class can be instantiated without errors + @isTest + static void testExtensionInstantiation() { + PlaceOrderActionExtensionSample extension = new PlaceOrderActionExtensionSample(); + System.assert(extension != null, 'Extension should be successfully instantiated'); + } + +} \ No newline at end of file diff --git a/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSampleTest.cls-meta.xml b/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSampleTest.cls-meta.xml new file mode 100644 index 0000000..82775b9 --- /dev/null +++ b/commerce/domain/checkout/order/hooks/classes/PlaceOrderActionExtensionSampleTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSample.cls b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSample.cls new file mode 100644 index 0000000..a8e6b72 --- /dev/null +++ b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSample.cls @@ -0,0 +1,110 @@ +/* +* Use Case: +* =============================== +* This sample covers the use case of validating the products in the order before it is placed by making a callout to the external ERP system. +* If the ERP validation is successful, the order will proceed as normal. +* If not, the order creation gets failed. +* +* This code sample throws a CalloutException if the validation fails, this should be modified according to the customer use case. +*/ +public virtual class PlaceOrderValidateSample extends CartExtension.CheckoutPlaceOrder { + + public virtual override CartExtension.PlaceOrderResponse validate(CartExtension.PlaceOrderRequest placeOrderRequest, List domainList) { + + CartExtension.Cart cart = placeOrderRequest.getCart(); + CartExtension.CartItemList cartItems = cart.getCartItems(); + + // Get the list of product IDs from cart items + List productIds = new List(); + for (Integer i = (cartItems.size() - 1); i >= 0; i--) { + CartExtension.CartItem cartItem = cartItems.get(i); + ID productId = cartItem.getProduct2Id(); + productIds.add(productId); + } + + System.debug('Product IDs list: ' + productIds); + + // You MUST change this to be your service or you must launch your own Third Party Service + // and add the host in Setup | Security | Remote site settings. + String url = 'https://example.com'; + + // Add product IDs as query parameters to the URL + // Adding products to the URL as query parameters this should be modified depending on the API contract. + if (productIds != null && !productIds.isEmpty()) { + String productIdsParam = String.join(productIds, ','); + url += '?productIds=' + EncodingUtil.urlEncode(productIdsParam, 'UTF-8'); + } + + // Create an HTTP object and request + Http http = new Http(); + HttpRequest request = new HttpRequest(); + + // Set the HTTP request properties + request.setEndpoint(url); + request.setMethod('GET'); + + try { + // Send the HTTP request and get the response + HttpResponse response = http.send(request); + + // Handle the response (log or process it) + if (response.getStatusCode() == 200) { + System.debug('Callout successful! Response: ' + response.getBody()); + + // Parse the JSON response body + String responseBody = response.getBody(); + + // Process the response + processResponse(responseBody); + } else { + throw new CalloutException( + 'There was a problem with the request. Error: ' + response.getStatusCode() + ); + } + } catch (Exception e) { + // Handle any other exceptions that occur during the HTTP callout + System.debug('Exception during callout: ' + e.getMessage()); + throw new CalloutException('Exception during validation callout: ' + e.getMessage()); + } + + // Call the default validate method to validate the order + return super.validate(placeOrderRequest, domainList); + } + + /** + * Processes the HTTP response body to determine if order placement should proceed. + * Throws CalloutException if validation fails, allowing the order placement to be blocked. + * @param responseBody The JSON response body from the HTTP callout + * @throws CalloutException if response is invalid or status does not indicate success + */ + private void processResponse(String responseBody) { + if (responseBody != null && responseBody.trim().length() > 0) { + try { + // Parse JSON response + Map responseMap = (Map) JSON.deserializeUntyped(responseBody); + + // Check for status field that indicates whether to proceed + String status = (String) responseMap.get('status'); + + // Check if status indicates we should proceed + if (status != null && status.equalsIgnoreCase('success')) { + // Everything is alright, proceed further + System.debug('Validation successful. Proceeding with order placement.'); + } else { + // Response status indicates failure, throw exception + throw new CalloutException( + 'Validation failed. Response status does not indicate success. Status: ' + status + ); + } + } catch (Exception e) { + // If JSON parsing fails, throw exception + throw new CalloutException( + 'Failed to parse response JSON: ' + e.getMessage() + '. Response body: ' + responseBody + ); + } + } else { + // Empty response body, throw exception + throw new CalloutException('Validation failed. Empty response body received.'); + } + } +} diff --git a/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSample.cls-meta.xml b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSample.cls-meta.xml new file mode 100644 index 0000000..b009296 --- /dev/null +++ b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 67.0 + Active + diff --git a/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSampleTest.cls b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSampleTest.cls new file mode 100644 index 0000000..81ed5d6 --- /dev/null +++ b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSampleTest.cls @@ -0,0 +1,381 @@ +/** + * @description Unit tests for PlaceOrderValidateSample class. + * Tests the validation logic for order placement including HTTP callout scenarios. + */ +@isTest +private class PlaceOrderValidateSampleTest { + + @isTest + static void testValidateSuccessWithStatusSuccess() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(2); + PlaceOrderValidateSample validator = new PlaceOrderValidateSampleMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List(); + + // Set up mock for successful response with status "success" + Test.setMock(HttpCalloutMock.class, new SuccessCalloutMock()); + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + // When validation succeeds, processResponse() doesn't throw, so super.validate() is called + // The response comes from the parent class's validate method + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateFailureWithStatusFailure() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderValidateSample validator = new PlaceOrderValidateSample(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List(); + + // Set up mock for response with status "failure" + Test.setMock(HttpCalloutMock.class, new FailureStatusCalloutMock()); + + // Act & Assert + Test.startTest(); + try { + validator.validate(placeOrderRequest, domainList); + System.assert(false, 'Expected CalloutException to be thrown'); + } catch (CalloutException e) { + System.assert(e.getMessage().contains('Validation failed'), 'Exception message should indicate validation failure'); + System.assert(e.getMessage().contains('Status: failure'), 'Exception message should contain status'); + } + Test.stopTest(); + } + + @isTest + static void testValidateFailureWithNon200StatusCode() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderValidateSample validator = new PlaceOrderValidateSample(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List(); + + // Set up mock for HTTP 500 error + Test.setMock(HttpCalloutMock.class, new HttpErrorCalloutMock(500)); + + // Act & Assert + Test.startTest(); + try { + validator.validate(placeOrderRequest, domainList); + System.assert(false, 'Expected CalloutException to be thrown'); + } catch (CalloutException e) { + System.assert(e.getMessage().contains('There was a problem with the request'), 'Exception message should indicate request problem'); + System.assert(e.getMessage().contains('500'), 'Exception message should contain status code'); + } + Test.stopTest(); + } + + @isTest + static void testValidateFailureWithEmptyResponseBody() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderValidateSample validator = new PlaceOrderValidateSample(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List(); + + // Set up mock for empty response body + Test.setMock(HttpCalloutMock.class, new EmptyResponseCalloutMock()); + + // Act & Assert + Test.startTest(); + try { + validator.validate(placeOrderRequest, domainList); + System.assert(false, 'Expected CalloutException to be thrown'); + } catch (CalloutException e) { + System.assert(e.getMessage().contains('Empty response body'), 'Exception message should indicate empty response'); + } + Test.stopTest(); + } + + @isTest + static void testValidateFailureWithInvalidJson() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderValidateSample validator = new PlaceOrderValidateSample(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List(); + + // Set up mock for invalid JSON response + Test.setMock(HttpCalloutMock.class, new InvalidJsonCalloutMock()); + + // Act & Assert + Test.startTest(); + try { + validator.validate(placeOrderRequest, domainList); + System.assert(false, 'Expected CalloutException to be thrown'); + } catch (CalloutException e) { + System.assert(e.getMessage().contains('Failed to parse response JSON'), 'Exception message should indicate JSON parsing failure'); + } + Test.stopTest(); + } + + @isTest + static void testValidateWithMultipleProductIds() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(3); + PlaceOrderValidateSample validator = new PlaceOrderValidateSampleMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List(); + + // Set up mock for successful response + Test.setMock(HttpCalloutMock.class, new SuccessCalloutMock()); + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateWithNoProductIds() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(0); + PlaceOrderValidateSample validator = new PlaceOrderValidateSampleMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List(); + + // Set up mock for successful response + Test.setMock(HttpCalloutMock.class, new SuccessCalloutMock()); + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateWithNullStatus() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderValidateSample validator = new PlaceOrderValidateSample(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List(); + + // Set up mock for response with null status + Test.setMock(HttpCalloutMock.class, new NullStatusCalloutMock()); + + // Act & Assert + Test.startTest(); + try { + validator.validate(placeOrderRequest, domainList); + System.assert(false, 'Expected CalloutException to be thrown'); + } catch (CalloutException e) { + System.assert(e.getMessage().contains('Validation failed'), 'Exception message should indicate validation failure'); + } + Test.stopTest(); + } + + /** + * Helper method to create a test cart with products + */ + private static CartExtension.Cart arrangeCartWithProducts(Integer productCount) { + Account testAccount = new Account(Name = 'Test Account'); + insert testAccount; + + WebStore testWebStore = new WebStore(Name = 'Test WebStore'); + insert testWebStore; + + WebCart testCart = new WebCart(Name = 'Test Cart', WebStoreId = testWebStore.Id, AccountId = testAccount.Id); + insert testCart; + + CartDeliveryGroup testDeliveryGroup = new CartDeliveryGroup(Name = 'Test Delivery Group', CartId = testCart.Id); + insert testDeliveryGroup; + + List testProducts = new List(); + for (Integer i = 0; i < productCount; i++) { + Product2 testProduct = new Product2(Name = 'Test Product ' + i, IsActive = true); + testProducts.add(testProduct); + } + if (!testProducts.isEmpty()) { + insert testProducts; + } + + List testCartItems = new List(); + for (Product2 product : testProducts) { + CartItem testCartItem = new CartItem( + Name = 'Test Cart Item', + SalesPrice = 10.00, + CartId = testCart.Id, + CartDeliveryGroupId = testDeliveryGroup.Id, + Product2Id = product.Id, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name() + ); + testCartItems.add(testCartItem); + } + if (!testCartItems.isEmpty()) { + insert testCartItems; + } + + return CartExtension.CartTestUtil.getCart(testCart.Id); + } + + /** + * Mock class for PlaceOrderValidateSample that provides a testable implementation. + * This mock extends the main class and overrides validate to handle super.validate() call + * by returning a mock success response instead of calling the parent's super.validate(). + */ + @TestVisible + private class PlaceOrderValidateSampleMock extends PlaceOrderValidateSample { + + public override CartExtension.PlaceOrderResponse validate(CartExtension.PlaceOrderRequest placeOrderRequest, List domainList) { + // Call the parent validate method which includes HTTP callout and processResponse logic + // The parent method will throw CalloutException if validation fails + // If validation succeeds, we need to return a response instead of calling super.validate() + // Since we can't easily mock super.validate(), we'll replicate the logic here + + CartExtension.Cart cart = placeOrderRequest.getCart(); + CartExtension.CartItemList cartItems = cart.getCartItems(); + + List productIds = new List(); + for (Integer i = (cartItems.size() - 1); i >= 0; i--) { + CartExtension.CartItem cartItem = cartItems.get(i); + ID productId = cartItem.getProduct2Id(); + productIds.add(productId); + } + + String url = 'https://example.com'; + if (productIds != null && !productIds.isEmpty()) { + String productIdsParam = String.join(productIds, ','); + url += '?productIds=' + EncodingUtil.urlEncode(productIdsParam, 'UTF-8'); + } + + Http http = new Http(); + HttpRequest request = new HttpRequest(); + request.setEndpoint(url); + request.setMethod('GET'); + + HttpResponse response = http.send(request); + + if (response.getStatusCode() == 200) { + String responseBody = response.getBody(); + // Call processResponse logic - it will throw if validation fails + processResponseMock(responseBody); + // Return mock success response instead of calling super.validate() + return CartExtension.PlaceOrderResponse.success(); + } else { + throw new CalloutException('There was a problem with the request. Error: ' + response.getStatusCode()); + } + } + + // Replicate processResponse logic for testing + private void processResponseMock(String responseBody) { + if (responseBody != null && responseBody.trim().length() > 0) { + try { + Map responseMap = (Map) JSON.deserializeUntyped(responseBody); + String status = (String) responseMap.get('status'); + + if (status != null && status.equalsIgnoreCase('success')) { + System.debug('Validation successful. Proceeding with order placement.'); + } else { + throw new CalloutException('Validation failed. Response status does not indicate success. Status: ' + status); + } + } catch (Exception e) { + if (e instanceof CalloutException) { + throw e; + } + throw new CalloutException('Failed to parse response JSON: ' + e.getMessage() + '. Response body: ' + responseBody); + } + } else { + throw new CalloutException('Validation failed. Empty response body received.'); + } + } + } + + /** + * Mock class for successful HTTP callout with status "success" + */ + private class SuccessCalloutMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + res.setBody('{"status":"success"}'); + res.setStatusCode(200); + return res; + } + } + + /** + * Mock class for HTTP callout with status "failure" + */ + private class FailureStatusCalloutMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + res.setBody('{"status":"failure"}'); + res.setStatusCode(200); + return res; + } + } + + /** + * Mock class for HTTP error responses + */ + private class HttpErrorCalloutMock implements HttpCalloutMock { + private Integer statusCode; + + public HttpErrorCalloutMock(Integer statusCode) { + this.statusCode = statusCode; + } + + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + res.setBody('{"error":"Internal Server Error"}'); + res.setStatusCode(statusCode); + return res; + } + } + + /** + * Mock class for empty response body + */ + private class EmptyResponseCalloutMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + res.setBody(''); + res.setStatusCode(200); + return res; + } + } + + /** + * Mock class for invalid JSON response + */ + private class InvalidJsonCalloutMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + res.setBody('{invalid json}'); + res.setStatusCode(200); + return res; + } + } + + /** + * Mock class for response with null status field + */ + private class NullStatusCalloutMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + res.setBody('{"otherField":"value"}'); + res.setStatusCode(200); + return res; + } + } +} diff --git a/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSampleTest.cls-meta.xml b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSampleTest.cls-meta.xml new file mode 100644 index 0000000..b009296 --- /dev/null +++ b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderValidateSampleTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 67.0 + Active + diff --git a/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequence.cls b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequence.cls new file mode 100644 index 0000000..333ab46 --- /dev/null +++ b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequence.cls @@ -0,0 +1,54 @@ +/* +* Use Case: +* =============================== +* This sample covers the use case of performing validations for each domain in a specific sequence before placing an order. +* The domainList can contain domain types such as: TAXES, PRICING, PROMOTIONS, SHIPPING +* Validations are performed in the following sequence: PRICING, PROMOTIONS, SHIPPING, TAXES +* Only domains that are present in the domainList will be validated. +* Each domain is validated one after another by calling super.validate() for each domain. +* If any domain validation fails, the order placement is blocked and subsequent domains are not validated. +* If all domain validations are successful, the order will proceed as normal. +* +* This code sample will throw an exception if any domain validation fails, this should be modified according to the customer use case. +*/ +public virtual class PlaceOrderWithValidationsInSequence extends CartExtension.CheckoutPlaceOrder { + + public virtual override CartExtension.PlaceOrderResponse validate(CartExtension.PlaceOrderRequest placeOrderRequest, List domainList) { + + // Validate domains in the specified sequence: PRICING, PROMOTIONS, SHIPPING, TAXES + // Only validate domains that are present in the domainList + CartExtension.PlaceOrderResponse response = null; + + // Define the validation sequence + List validationSequence = new List{ 'PRICING', 'PROMOTIONS', 'SHIPPING', 'TAXES' }; + + if (domainList != null && !domainList.isEmpty()) { + // Convert domainList to Set for efficient lookup + Set domainSet = new Set(domainList); + + // Validate each domain in the specified sequence + for (String domain : validationSequence) { + // Only validate if this domain is present in the domainList + if (domainSet.contains(domain)) { + System.debug('Validating domain: ' + domain); + + // Create a single-item domain list for this domain + List singleDomainList = new List{ domain }; + + // Call super.validate() for this domain + // If validation fails, it will throw an exception and stop the sequence + response = super.validate(placeOrderRequest, singleDomainList); + + System.debug('Domain ' + domain + ' validation completed successfully'); + } else { + System.debug('Domain ' + domain + ' not in domainList, skipping validation'); + } + } + } else { + // If no domain list provided, call super.validate() with empty list + response = super.validate(placeOrderRequest, domainList); + } + + return response; + } +} diff --git a/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequence.cls-meta.xml b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequence.cls-meta.xml new file mode 100644 index 0000000..b009296 --- /dev/null +++ b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequence.cls-meta.xml @@ -0,0 +1,5 @@ + + + 67.0 + Active + diff --git a/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequenceTest.cls b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequenceTest.cls new file mode 100644 index 0000000..846af62 --- /dev/null +++ b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequenceTest.cls @@ -0,0 +1,366 @@ +/** + * @description Unit tests for PlaceOrderWithValidationsInSequence class. + * Tests the sequential domain validation logic for order placement. + */ +@isTest +private class PlaceOrderWithValidationsInSequenceTest { + + @isTest + static void testValidateSuccessWithAllDomainsInSequence() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(2); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequenceMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List{ 'PRICING', 'PROMOTIONS', 'SHIPPING', 'TAXES' }; + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + // All domains should be validated in sequence: PRICING, PROMOTIONS, SHIPPING, TAXES + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateSuccessWithPartialDomains() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequenceMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + // Only PRICING and TAXES in domainList, should skip PROMOTIONS and SHIPPING + List domainList = new List{ 'PRICING', 'TAXES' }; + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + // Should validate PRICING first, skip PROMOTIONS and SHIPPING, then validate TAXES + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateSuccessWithDomainsInDifferentOrder() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequenceMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + // DomainList has different order, but should still validate in fixed sequence + List domainList = new List{ 'TAXES', 'PRICING', 'SHIPPING', 'PROMOTIONS' }; + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + // Should still validate in sequence: PRICING, PROMOTIONS, SHIPPING, TAXES + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateSuccessWithOnlyFirstDomain() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequenceMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + // Only PRICING in domainList + List domainList = new List{ 'PRICING' }; + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateSuccessWithOnlyLastDomain() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequenceMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + // Only TAXES in domainList + List domainList = new List{ 'TAXES' }; + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateWithEmptyDomainList() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequenceMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List(); + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + // Should call super.validate() with empty list + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateWithNullDomainList() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequenceMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = null; + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + // Should call super.validate() with null list + System.assertNotEquals(null, response, 'Response should not be null'); + } + + @isTest + static void testValidateFailureWithFirstDomainFailing() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequence(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List{ 'PRICING', 'PROMOTIONS', 'SHIPPING', 'TAXES' }; + + // Set up mock to fail on PRICING (first domain) + Test.setMock(HttpCalloutMock.class, new FirstDomainFailureCalloutMock()); + + // Act & Assert + Test.startTest(); + try { + validator.validate(placeOrderRequest, domainList); + System.assert(false, 'Expected exception to be thrown'); + } catch (Exception e) { + // Should fail on PRICING validation + System.assert(true, 'Exception thrown as expected: ' + e.getMessage()); + } + Test.stopTest(); + } + + @isTest + static void testValidateFailureWithMiddleDomainFailing() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequence(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List{ 'PRICING', 'PROMOTIONS', 'SHIPPING', 'TAXES' }; + + // Set up mock to fail on PROMOTIONS (middle domain) + Test.setMock(HttpCalloutMock.class, new MiddleDomainFailureCalloutMock()); + + // Act & Assert + Test.startTest(); + try { + validator.validate(placeOrderRequest, domainList); + System.assert(false, 'Expected exception to be thrown'); + } catch (Exception e) { + // Should fail on PROMOTIONS validation + System.assert(true, 'Exception thrown as expected: ' + e.getMessage()); + } + Test.stopTest(); + } + + @isTest + static void testValidateFailureWithLastDomainFailing() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequence(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + List domainList = new List{ 'PRICING', 'PROMOTIONS', 'SHIPPING', 'TAXES' }; + + // Set up mock to fail on TAXES (last domain) + Test.setMock(HttpCalloutMock.class, new LastDomainFailureCalloutMock()); + + // Act & Assert + Test.startTest(); + try { + validator.validate(placeOrderRequest, domainList); + System.assert(false, 'Expected exception to be thrown'); + } catch (Exception e) { + // Should fail on TAXES validation + System.assert(true, 'Exception thrown as expected: ' + e.getMessage()); + } + Test.stopTest(); + } + + @isTest + static void testValidateWithSingleDomainInMiddle() { + // Arrange + CartExtension.Cart cart = arrangeCartWithProducts(1); + PlaceOrderWithValidationsInSequence validator = new PlaceOrderWithValidationsInSequenceMock(); + CartExtension.PlaceOrderRequest placeOrderRequest = new CartExtension.PlaceOrderRequest(cart); + // Only SHIPPING in domainList (middle domain) + List domainList = new List{ 'SHIPPING' }; + + // Act + Test.startTest(); + CartExtension.PlaceOrderResponse response = validator.validate(placeOrderRequest, domainList); + Test.stopTest(); + + // Assert + // Should skip PRICING and PROMOTIONS, validate SHIPPING, skip TAXES + System.assertNotEquals(null, response, 'Response should not be null'); + } + + /** + * Helper method to create a test cart with products + */ + private static CartExtension.Cart arrangeCartWithProducts(Integer productCount) { + Account testAccount = new Account(Name = 'Test Account'); + insert testAccount; + + WebStore testWebStore = new WebStore(Name = 'Test WebStore'); + insert testWebStore; + + WebCart testCart = new WebCart(Name = 'Test Cart', WebStoreId = testWebStore.Id, AccountId = testAccount.Id); + insert testCart; + + CartDeliveryGroup testDeliveryGroup = new CartDeliveryGroup(Name = 'Test Delivery Group', CartId = testCart.Id); + insert testDeliveryGroup; + + List testProducts = new List(); + for (Integer i = 0; i < productCount; i++) { + Product2 testProduct = new Product2(Name = 'Test Product ' + i, IsActive = true); + testProducts.add(testProduct); + } + if (!testProducts.isEmpty()) { + insert testProducts; + } + + List testCartItems = new List(); + for (Product2 product : testProducts) { + CartItem testCartItem = new CartItem( + Name = 'Test Cart Item', + SalesPrice = 10.00, + CartId = testCart.Id, + CartDeliveryGroupId = testDeliveryGroup.Id, + Product2Id = product.Id, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name() + ); + testCartItems.add(testCartItem); + } + if (!testCartItems.isEmpty()) { + insert testCartItems; + } + + return CartExtension.CartTestUtil.getCart(testCart.Id); + } + + /** + * Mock class for PlaceOrderWithValidationsInSequence that provides a testable implementation. + * This mock extends the main class and overrides validate to handle super.validate() calls + * by returning a mock success response instead of calling the parent's super.validate(). + */ + @TestVisible + private class PlaceOrderWithValidationsInSequenceMock extends PlaceOrderWithValidationsInSequence { + + // Track which domains were validated + public List validatedDomains = new List(); + + public override CartExtension.PlaceOrderResponse validate(CartExtension.PlaceOrderRequest placeOrderRequest, List domainList) { + // Replicate the parent logic but track domains and return mock response + CartExtension.PlaceOrderResponse response = null; + + List validationSequence = new List{ 'PRICING', 'PROMOTIONS', 'SHIPPING', 'TAXES' }; + + if (domainList != null && !domainList.isEmpty()) { + Set domainSet = new Set(domainList); + + for (String domain : validationSequence) { + if (domainSet.contains(domain)) { + System.debug('Validating domain: ' + domain); + validatedDomains.add(domain); + + // Simulate successful validation + response = CartExtension.PlaceOrderResponse.success(); + + System.debug('Domain ' + domain + ' validation completed successfully'); + } else { + System.debug('Domain ' + domain + ' not in domainList, skipping validation'); + } + } + } else { + // If no domain list provided, return mock success response + response = CartExtension.PlaceOrderResponse.success(); + } + + return response; + } + } + + /** + * Mock class for HTTP callout that fails on first domain (PRICING) + */ + private class FirstDomainFailureCalloutMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + res.setBody('{"status":"failure"}'); + res.setStatusCode(200); + return res; + } + } + + /** + * Mock class for HTTP callout that fails on middle domain (PROMOTIONS) + */ + private class MiddleDomainFailureCalloutMock implements HttpCalloutMock { + private Integer callCount = 0; + + public HttpResponse respond(HttpRequest req) { + callCount++; + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + + // First domain (PRICING) succeeds, second domain (PROMOTIONS) fails + if (callCount == 2) { + res.setBody('{"status":"failure"}'); + } else { + res.setBody('{"status":"success"}'); + } + res.setStatusCode(200); + return res; + } + } + + /** + * Mock class for HTTP callout that fails on last domain (TAXES) + */ + private class LastDomainFailureCalloutMock implements HttpCalloutMock { + private Integer callCount = 0; + + public HttpResponse respond(HttpRequest req) { + callCount++; + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + + // First three domains succeed, last domain (TAXES) fails + if (callCount == 4) { + res.setBody('{"status":"failure"}'); + } else { + res.setBody('{"status":"success"}'); + } + res.setStatusCode(200); + return res; + } + } +} diff --git a/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequenceTest.cls-meta.xml b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequenceTest.cls-meta.xml new file mode 100644 index 0000000..b009296 --- /dev/null +++ b/commerce/domain/checkout/order/placeOrder/classes/PlaceOrderWithValidationsInSequenceTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 67.0 + Active + diff --git a/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSample.cls b/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSample.cls new file mode 100644 index 0000000..86c0fd6 --- /dev/null +++ b/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSample.cls @@ -0,0 +1,56 @@ +/** + * @description + * + * USE CASE + * + * The customer was notified by the warehouse that during the inventory process, SKU_RED_SHIRT was accidentally made available. + * Due to a system issue, the warehouse is currently unable to mark it as unavailable again. + * Customer does NOT want to sell the SKU_RED_SHIRT in the store front + * + * SI Solution + * Extended inventory calculator to show a message during checkout that SKU_RED_SHIRT that shipping migth be delay + * + */ + public class InventoryCartCalculatorSample extends CartExtension.InventoryCartCalculator { + + + private static final String SKU_RED_SHIRT = 'SKU_RED_SHIRT'; + private static final String SKU_RED_SHIRT_MESSAGE = 'Apologies for the inconvenience, but there is an issue with SKU_RED_SHIRT, and it is currently unavailable.'; + + public InventoryCartCalculatorSample() { + super(); + } + + /** + * @description Constructor used by unit tests only. + * @param apexExecutor Executor which executes various calculators. Can be used to stub calculation results or delegate calculations to actual Calculator. See <>. + */ + public InventoryCartCalculatorSample(CartExtension.CartCalculateExecutorMock apexExecutor) { + // Must call super constructor in order for provided Executor to be used for calculations + super(apexExecutor); + } + + public virtual override void calculate(CartExtension.CartCalculateCalculatorRequest request) { + + CartExtension.Cart cart = request.getCart(); + + super.calculate(request); + + CartExtension.CartItemList cartItemCollection = cart.getCartItems(); + Iterator cartItemCollectionIterator = cartItemCollection.iterator(); + + while (cartItemCollectionIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemCollectionIterator.next(); + + system.debug('CART ITEM SKU: ' + cartItem.getSku()); + if (cartItem.getSku().equals(SKU_RED_SHIRT)) { + CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput( + CartExtension.CartValidationOutputTypeEnum.INVENTORY, + CartExtension.CartValidationOutputLevelEnum.ERROR, cartItem); + cvo.setMessage(SKU_RED_SHIRT_MESSAGE); + cart.getCartValidationOutputs().add(cvo); + system.debug('CART CVO: ' + cvo); + } + } + } +} \ No newline at end of file diff --git a/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSample.cls-meta.xml b/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSample.cls-meta.xml new file mode 100644 index 0000000..7d5f9e8 --- /dev/null +++ b/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + \ No newline at end of file diff --git a/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSampleTest.cls b/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSampleTest.cls new file mode 100644 index 0000000..315ded4 --- /dev/null +++ b/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSampleTest.cls @@ -0,0 +1,150 @@ +@isTest +public class InventoryCartCalculatorSampleTest { + + private static final String CART_NAME = 'My Cart'; + private static final String ACCOUNT_NAME = 'My Account'; + private static final String WEBSTORE_NAME = 'My WebStore'; + private static final String DELIVERYGROUP_NAME = 'My Delivery Group'; + private static final String CART_ITEM1_NAME = 'My Cart Item 1'; + private static final String CART_ITEM2_NAME = 'My Cart Item 2'; + private static final String CART_ITEM3_NAME = 'My Cart Item 3'; + private static final String SKU1_NAME = 'My SKU 1'; + private static final String SKU2_NAME = 'My SKU 2'; + private static final String SKU_RED_SHIRT = 'SKU_RED_SHIRT'; + private static final String SKU_RED_SHIRT_MESSAGE = 'Apologies for the inconvenience, but there is an issue with SKU_RED_SHIRT, and it is currently unavailable.'; + + @IsTest + public static void testWithSkuRedShirtNotInCart() { + + // Arrange + CartExtension.Cart cart = arrangeAndLoadCartWithSpecifiedStatusAndThreeItems(CartExtension.CartStatusEnum.CHECKOUT, false); + InventoryCartCalculatorSample calculator = new InventoryCartCalculatorSample(new DefaultInventoryCartCalculatoMockExecutor()); + + Test.startTest(); + + // Act + calculator.calculate(new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty())); + + Test.stopTest(); + + // Assert + assertCartValidationOutputs(cart, 0); + + + } + + @IsTest + public static void testWithSkuRedShirtInCart() { + + // Arrange + CartExtension.Cart cart = arrangeAndLoadCartWithSpecifiedStatusAndThreeItems(CartExtension.CartStatusEnum.CHECKOUT, true); + InventoryCartCalculatorSample calculator = new InventoryCartCalculatorSample(new DefaultInventoryCartCalculatoMockExecutor()); + + Test.startTest(); + + // Act + calculator.calculate(new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty())); + + Test.stopTest(); + + // Assert + assertCartValidationOutputs(cart, 1); + + + } + + + /** + * @description Sample mock executor. Stubs result of default pricing calculator + */ + public class DefaultInventoryCartCalculatoMockExecutor extends CartExtension.CartCalculateExecutorMock { + + /** + * @description This constructor should only be exposed to customers in a test context + */ + public DefaultInventoryCartCalculatoMockExecutor() {} + + public override void defaultInventory(CartExtension.CartCalculateCalculatorRequest request) { + // avoid the call to the implementation + return; + } + + } + + private static CartExtension.Cart arrangeAndLoadCartWithSpecifiedStatusAndThreeItems(CartExtension.CartStatusEnum cartStatus, boolean ignoreRedShirtSku) { + Id cartId = arrangeCartWithSpecifiedStatus(cartStatus); + arrangeThreeCartItems(cartId, ignoreRedShirtSku); + return CartExtension.CartTestUtil.getCart(cartId); + } + + private static ID arrangeCartWithSpecifiedStatus(CartExtension.CartStatusEnum cartStatus) { + Account account = new Account(Name = ACCOUNT_NAME); + insert account; + + WebStore webStore = new WebStore(Name = WEBSTORE_NAME, OptionsCartCalculateEnabled = true); + insert webStore; + + WebCart webCart = new WebCart( + Name = CART_NAME, + WebStoreId = webStore.Id, + AccountId = account.Id, + Status = cartStatus.name()); + insert webCart; + return webCart.Id; + } + + private static List arrangeThreeCartItems(ID cartId, Boolean ignoreRedShirtSku) { + CartDeliveryGroup deliveryGroup = new CartDeliveryGroup(Name = DELIVERYGROUP_NAME, CartId = cartId); + insert deliveryGroup; + + CartItem cartItem1 = new CartItem( + Name = CART_ITEM1_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 3, + SKU = SKU1_NAME, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem1; + + CartItem cartItem2 = new CartItem( + Name = CART_ITEM2_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 3, + SKU = SKU2_NAME, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem2; + + CartItem cartItem3 = new CartItem( + Name = CART_ITEM3_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 3, + SKU = SKU_RED_SHIRT, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + + if (ignoreRedShirtSku) { + insert cartItem3; + } + + if (ignoreRedShirtSku) { + return new List{cartItem1.Id, cartItem2.Id}; + } else { + return new List{cartItem1.Id, cartItem2.Id, cartItem3.Id}; + } + + } + + private static void assertCartValidationOutputs(CartExtension.Cart cart, Integer expectedCVOCount) { + String errorString = ''; + Iterator cvoIterator = cart.getCartValidationOutputs().iterator(); + while (cvoIterator.hasNext()) { + errorString += cvoIterator.next().getMessage() ; + } + Assert.areEqual(expectedCVOCount, cart.getCartValidationOutputs().size(), 'No CartValidationOutputs expected, but was: ' + errorString); + + if (expectedCVOCount == 1) { + Assert.areEqual(SKU_RED_SHIRT_MESSAGE, errorString, SKU_RED_SHIRT_MESSAGE + ' expected, but was: ' + errorString); + } + } +} \ No newline at end of file diff --git a/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSampleTest.cls-meta b/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSampleTest.cls-meta new file mode 100644 index 0000000..7d5f9e8 --- /dev/null +++ b/commerce/domain/inventory/cart/calculator/classes/InventoryCartCalculatorSampleTest.cls-meta @@ -0,0 +1,5 @@ + + + 61.0 + Active + \ No newline at end of file diff --git a/commerce/domain/inventory/cart/calculator/package.xml b/commerce/domain/inventory/cart/calculator/package.xml new file mode 100644 index 0000000..825029a --- /dev/null +++ b/commerce/domain/inventory/cart/calculator/package.xml @@ -0,0 +1,8 @@ + + + + InventoryCartCalculatorSample + ApexClass + + 61.0 + \ No newline at end of file diff --git a/commerce/domain/inventory/service/classes/CommerceInventoryServiceSample.cls b/commerce/domain/inventory/service/classes/CommerceInventoryServiceSample.cls new file mode 100644 index 0000000..6578dd7 --- /dev/null +++ b/commerce/domain/inventory/service/classes/CommerceInventoryServiceSample.cls @@ -0,0 +1,93 @@ +public virtual class CommerceInventoryServiceSample extends commerce_inventory.CommerceInventoryService { + + public override commerce_inventory.UpsertReservationResponse upsertReservation(commerce_inventory.UpsertReservationRequest upsertReservationRequest, + commerce_inventory.InventoryReservation currentReservation, + String reservationChangeType) { + + commerce_inventory.UpsertReservationResponse response = new commerce_inventory.UpsertReservationResponse(); + + response.setSucceed(true); + response.setReservationSourceId(upsertReservationRequest.getReservationSourceId()); + response.setReservationIdentifier(upsertReservationRequest.getReservationIdentifier()); + List responseItems = new List(); + + for(commerce_inventory.UpsertItemReservationRequest item : upsertReservationRequest.getItems()) { + commerce_inventory.UpsertItemReservationResponse responseItem = new commerce_inventory.UpsertItemReservationResponse(); + responseItem.setQuantity(item.getQuantity()); + responseItem.setReservedAtLocationId(item.getReservedAtLocationId()); + responseItem.setItemReservationSourceId(item.getItemReservationSourceId()); + responseItem.setProductId(item.getProductId()); + responseItems.add(responseItem); + } + + response.setItems(responseItems); + + validateResponse(upsertReservationRequest.getItems().size(),response.getItems().size()); + + return response; + } + + public override commerce_inventory.DeleteReservationResponse deleteReservation(String reservationId, commerce_inventory.InventoryReservation currentReservation) { + return callDefaultDeleteReservation(reservationId, currentReservation); + } + + public override commerce_inventory.InventoryReservation getReservation(String reservationId) { + return callDefaultGetReservation(reservationId); + } + + public override commerce_inventory.InventoryCheckAvailability checkInventory(commerce_inventory.InventoryCheckAvailability request) { + for(commerce_inventory.InventoryCheckItemAvailability item : request.getInventoryCheckItemAvailability()) { + item.setAvailable(true); + } + return request; + } + + public override commerce_inventory.InventoryLevelsResponse getInventoryLevel(commerce_inventory.InventoryLevelsRequest request) { + + commerce_inventory.InventoryLevelsResponse response = new commerce_inventory.InventoryLevelsResponse(); + Set items = new Set(); + + Integer i = 0; + for(commerce_inventory.InventoryLevelsItemRequest item : request.getItemInventoryLevelRequests()) { + commerce_inventory.InventoryLevelsItemResponse itemResponse = new commerce_inventory.InventoryLevelsItemResponse(); + itemResponse.setProductId(item.getProductId()); + itemResponse.setLocationSourceId(item.getLocationSourceId()); + itemResponse.setInventoryLocationSourceType('LocationGroup'); + itemResponse.setOnHand(double.valueOf(i * 10)); + itemResponse.setAvailableToFulfill(double.valueOf(i * 10)); + itemResponse.setAvailableToOrder(double.valueOf(i * 10)); + items.add(itemResponse); + i = i + 1; + } + + response.setItemsInventoryLevels(items); + + validateResponse(request.getItemInventoryLevelRequests().size(),response.getItemsInventoryLevels().size()); + + return response; + + } + + @TestVisible + public virtual commerce_inventory.DeleteReservationResponse callDefaultDeleteReservation(String reservationId, commerce_inventory.InventoryReservation currentReservation) { + return super.deleteReservation(reservationId, currentReservation); + } + + @TestVisible + public virtual commerce_inventory.InventoryReservation callDefaultGetReservation(String reservationId) { + return super.getReservation(reservationId); + } + + private void validateResponse(Integer requestSize, Integer responseSize) { + + if (requestSize == 0) { + throw new InventoryValidationException('Invalid request size'); + } + + if (requestSize != responseSize) { + throw new InventoryValidationException('Invalid response from request expected: ' + requestSize + 'but got: ' + responseSize); + } + + } + +} \ No newline at end of file diff --git a/commerce/domain/inventory/service/classes/CommerceInventoryServiceSample.cls-meta.xml b/commerce/domain/inventory/service/classes/CommerceInventoryServiceSample.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/commerce/domain/inventory/service/classes/CommerceInventoryServiceSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/commerce/domain/inventory/service/classes/CommerceInventoryServiceSampleTest.cls b/commerce/domain/inventory/service/classes/CommerceInventoryServiceSampleTest.cls new file mode 100644 index 0000000..e972bab --- /dev/null +++ b/commerce/domain/inventory/service/classes/CommerceInventoryServiceSampleTest.cls @@ -0,0 +1,177 @@ +@isTest +public class CommerceInventoryServiceSampleTest { + + @IsTest + public static void testUpsertReservation() { + // Arrange + commerce_inventory.UpsertItemReservationRequest itemRequest = new commerce_inventory.UpsertItemReservationRequest(double.valueOf('10.0'), Id.valueOf('0ghSG0000000JFbYAM'), Id.valueOf('0a9SG000003AfY1YAK'), Id.valueOf('01tSG000001NNgKYAW'), null); + List itemReqeustList = new List(); + itemReqeustList.add(itemRequest); + commerce_inventory.upsertReservationRequest request = new commerce_inventory.upsertReservationRequest(Integer.valueOf(100),'4y2ml0e1oG2qrcfdbNnNyk', '0a9SG000003AfY1YAK', itemReqeustList); + CommerceInventoryServiceSample inventoryService = new CommerceInventoryServiceSample(); + + // Act + Test.startTest(); + commerce_inventory.UpsertReservationResponse actualResponse = inventoryService.upsertReservation(request, null,''); + Test.stopTest(); + + // Assert + commerce_inventory.UpsertItemReservationResponse itemActualResponse = actualResponse.getItems().get(0); + commerce_inventory.UpsertReservationResponse expectedResponse = createUpsertReservationResponse(); + commerce_inventory.UpsertItemReservationResponse itemExpectedResponse = expectedResponse.getItems().get(0); + + System.assertEquals(itemExpectedResponse.getProductId(), itemActualResponse.getProductId()); + + } + + @IsTest + public static void testDeleteReservation() { + // Arrange + CommerceInventoryServiceSampleMock inventoryServiceMock = new CommerceInventoryServiceSampleMock(); + + // Act + commerce_inventory.DeleteReservationResponse response = inventoryServiceMock.deleteReservation('10rxx000007LbIRAA0', null); + + // Assert + System.assertEquals(true, response.getSucceed()); + } + + @IsTest + public static void testGetReservation() { + + // Arrange + CommerceInventoryServiceSampleMock inventoryServiceMock = new CommerceInventoryServiceSampleMock(); + + // Act + commerce_inventory.InventoryReservation response = inventoryServiceMock.getReservation('10rxx000007LbIRAA0'); + commerce_inventory.InventoryReservation response2 = inventoryServiceMock.getReservation('10rxx000007LbIRAA1'); + + // Assert + System.assertNotEquals(null, response); + System.assertEquals(null, response2); + } + + @IsTest + public static void testCheckInventory() { + // Arrange + commerce_inventory.InventoryCheckItemAvailability itemRequest = new commerce_inventory.InventoryCheckItemAvailability(double.valueOf('10.0'),double.valueOf('10.0'),double.valueOf('10.0'),double.valueOf('10.0'),'OnHand','10rxx000007LbIRAA0','10rxx000007LbIRAA0','LocationGroup'); + Set itemReqeustSet = new Set(); + itemReqeustSet.add(itemRequest); + commerce_inventory.InventoryCheckAvailability request = new commerce_inventory.InventoryCheckAvailability(itemReqeustSet); + + CommerceInventoryServiceSample inventoryService = new CommerceInventoryServiceSample(); + // Act + Test.startTest(); + commerce_inventory.InventoryCheckAvailability actualResponse = inventoryService.checkInventory(request); + Test.stopTest(); + // Assert + commerce_inventory.InventoryCheckItemAvailability itemActualResponse; + for (commerce_inventory.InventoryCheckItemAvailability item : actualResponse.getInventoryCheckItemAvailability()) { + itemActualResponse = item; + break; + } + System.assertEquals(true, itemActualResponse.isAvailable()); + } + + @IsTest + public static void testgetInventoryLevel() { + // Arrange + commerce_inventory.InventoryLevelsItemRequest itemRequest = new commerce_inventory.InventoryLevelsItemRequest('01txx0000001aBcAAI', 'sku1','a1Bxx0000005T9E'); + Set itemRequestSet = new Set(); + itemRequestSet.add(itemRequest); + commerce_inventory.InventoryLevelsRequest request = new commerce_inventory.InventoryLevelsRequest(null, itemRequestSet); + + CommerceInventoryServiceSample inventoryService = new CommerceInventoryServiceSample(); + // Act + Test.startTest(); + commerce_inventory.InventoryLevelsResponse actualResponse = inventoryService.getInventoryLevel(request); + Test.stopTest(); + // Assert + commerce_inventory.InventoryLevelsItemResponse itemActualResponse; + for (commerce_inventory.InventoryLevelsItemResponse item : actualResponse.getItemsInventoryLevels()) { + itemActualResponse = item; + break; + } + commerce_inventory.InventoryLevelsResponse expectedResponse = createInventoryLevelsResponse(); + commerce_inventory.InventoryLevelsItemResponse itemExpectedResponse; + for (commerce_inventory.InventoryLevelsItemResponse item : expectedResponse.getItemsInventoryLevels()) { + itemexpectedResponse = item; + break; + } + + System.assertEquals(itemExpectedResponse.getProductId(), itemActualResponse.getProductId()); + } + + @IsTest + public static void testgetInventoryLevelDataValidation() { + + // Arrange + Set itemRequest = new Set(); + commerce_inventory.InventoryLevelsRequest request = new commerce_inventory.InventoryLevelsRequest(null, itemRequest); + CommerceInventoryServiceSample inventoryService = new CommerceInventoryServiceSample(); + + String errorMessage = ''; + + // Act + Test.startTest(); + try { + commerce_inventory.InventoryLevelsResponse actualResponse = inventoryService.getInventoryLevel(request); + errorMessage = 'Sucess Response'; + } catch(InventoryValidationException validationtEx) { + errorMessage = validationtEx.getMessage(); + + } + Test.stopTest(); + + // Assert + System.assertEquals(true,errorMessage.contains('Invalid request size')); + } + + private static commerce_inventory.UpsertReservationResponse createUpsertReservationResponse() { + commerce_inventory.UpsertReservationResponse response = new commerce_inventory.UpsertReservationResponse(); + List items = new List(); + commerce_inventory.UpsertItemReservationResponse itemResponse = new commerce_inventory.UpsertItemReservationResponse(); + itemResponse.setQuantity(double.valueOf('10.0')); + itemResponse.setReservedAtLocationId('0ghSG0000000JFbYAM'); + itemResponse.setItemReservationSourceId('0a9SG000003AfY1YAK'); + itemResponse.setProductId('01tSG000001NNgKYAW'); + items.add(itemResponse); + response.setItems(items); + return response; + } + + private static commerce_inventory.InventoryLevelsResponse createInventoryLevelsResponse() { + commerce_inventory.InventoryLevelsResponse response = new commerce_inventory.InventoryLevelsResponse(); + Set items = new Set(); + commerce_inventory.InventoryLevelsItemResponse itemResponse = new commerce_inventory.InventoryLevelsItemResponse(); + itemResponse.setProductId('01txx0000001aBcAAI'); + itemResponse.setLocationSourceId('a1Bxx0000005T9E'); + itemResponse.setInventoryLocationSourceType('LocationGroup'); + itemResponse.setOnHand(double.valueOf('10.0')); + itemResponse.setAvailableToFulfill(double.valueOf('10.0')); + itemResponse.setAvailableToOrder(double.valueOf('10.0')); + items.add(itemResponse); + response.setItemsInventoryLevels(items); + return response; + } + + private class CommerceInventoryServiceSampleMock extends CommerceInventoryServiceSample { + + public override commerce_inventory.DeleteReservationResponse callDefaultDeleteReservation(String reservationId, commerce_inventory.InventoryReservation currentReservation) { + commerce_inventory.DeleteReservationResponse responseMock = new commerce_inventory.DeleteReservationResponse(); + responseMock.setSucceed(true); + return responseMock; + } + + + public override commerce_inventory.InventoryReservation callDefaultGetReservation(String reservationId) { + if (reservationId == '10rxx000007LbIRAA0') { + commerce_inventory.InventoryReservation responseMock = new commerce_inventory.InventoryReservation(); + responseMock.setReservationIdentifier(reservationId); + return responseMock; + } else { + return null; + } + } + } +} \ No newline at end of file diff --git a/commerce/domain/inventory/service/classes/CommerceInventoryServiceSampleTest.cls-meta.xml b/commerce/domain/inventory/service/classes/CommerceInventoryServiceSampleTest.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/commerce/domain/inventory/service/classes/CommerceInventoryServiceSampleTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/commerce/domain/inventory/service/classes/InventoryValidationException.cls b/commerce/domain/inventory/service/classes/InventoryValidationException.cls new file mode 100644 index 0000000..e796dd6 --- /dev/null +++ b/commerce/domain/inventory/service/classes/InventoryValidationException.cls @@ -0,0 +1,4 @@ +public class InventoryValidationException extends Exception { + + +} \ No newline at end of file diff --git a/commerce/domain/inventory/service/classes/InventoryValidationException.cls-meta.xml b/commerce/domain/inventory/service/classes/InventoryValidationException.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/commerce/domain/inventory/service/classes/InventoryValidationException.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/commerce/domain/inventory/service/package.xml b/commerce/domain/inventory/service/package.xml new file mode 100644 index 0000000..aaedd06 --- /dev/null +++ b/commerce/domain/inventory/service/package.xml @@ -0,0 +1,9 @@ + + + + CommerceInventoryServiceSample + InventoryValidationException + ApexClass + + 61.0 + \ No newline at end of file diff --git a/commerce/domain/orchestrators/classes/CartCalculateSample.cls b/commerce/domain/orchestrators/classes/CartCalculateSample.cls index 56e292d..621ee1f 100644 --- a/commerce/domain/orchestrators/classes/CartCalculateSample.cls +++ b/commerce/domain/orchestrators/classes/CartCalculateSample.cls @@ -8,7 +8,7 @@ * Calculates shipping, post shipping and taxes for update shipping address operation. * Calculates taxes for select delivery method operation. */ -global class CartCalculateSample extends CartExtension.CartCalculate { +global with sharing class CartCalculateSample extends CartExtension.CartCalculate { /** * @description All classes extending CartExtension.CartCalculate must have a default constructor defined @@ -30,12 +30,44 @@ global class CartCalculateSample extends CartExtension.CartCalculate { // Use BuyerActions to decide which calculators to invoke CartExtension.BuyerActions buyerActions = request.getBuyerActions(); - boolean runPricing = buyerActions.isCheckoutStarted() || buyerActions.isCartItemChanged(); - boolean runPromotions = buyerActions.isCheckoutStarted() || buyerActions.isCouponChanged() || buyerActions.isCartItemChanged(); - boolean runInventory = buyerActions.isCheckoutStarted(); - boolean runShipping = buyerActions.isDeliveryGroupChanged(); - boolean runPostShipping = buyerActions.isDeliveryGroupChanged() || buyerActions.isDeliveryMethodSelected(); - boolean runTaxes = buyerActions.isDeliveryGroupChanged() || buyerActions.isDeliveryMethodSelected(); + boolean isCouponAppliedInCheckout = isCouponAppliedInCheckout(buyerActions, cart); + + boolean runPricing = buyerActions.isRecalculationRequested() || + buyerActions.isCheckoutStarted() || + buyerActions.isCartItemChanged(); + + boolean runPromotions = buyerActions.isRecalculationRequested() || + buyerActions.isCheckoutStarted() || + buyerActions.isCouponChanged() || + buyerActions.isCartItemChanged() || + isCouponAppliedInCheckout; + + boolean runInventory = isRecalculationRequestedInCheckout(buyerActions, cart) || + buyerActions.isCheckoutStarted() || + (buyerActions.isCartItemChanged() && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()); + + boolean runShipping = buyerActions.isEvaluateShippingRequested() || + (isRecalculationRequestedInCheckout(buyerActions, cart) && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()) || + isCouponAppliedInCheckout || + (buyerActions.isDeliveryGroupChanged() && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()); + + boolean runShippingPromotions = buyerActions.isEvaluateShippingRequested() || + (isRecalculationRequestedInCheckout(buyerActions, cart) && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()) || + (buyerActions.isDeliveryGroupChanged() && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()) || + isCouponAppliedInCheckout; + + boolean runPostShipping = buyerActions.isEvaluateShippingRequested() || + (isRecalculationRequestedInCheckout(buyerActions, cart) && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()) || + (buyerActions.isDeliveryGroupChanged() && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()) || + (buyerActions.isDeliveryMethodSelected() && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()) || + isCouponAppliedInCheckout; + + boolean runTaxes = buyerActions.isEvaluateTaxesRequested() || + (isRecalculationRequestedInCheckout(buyerActions, cart) && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()) || + (buyerActions.isDeliveryGroupChanged() && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()) || + (buyerActions.isDeliveryMethodSelected() && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus()) || + isCouponAppliedInCheckout; + // OptionalBuyerActionDetails can be used to optimize the various calculators that are invoked CartExtension.CartCalculateCalculatorRequest calculatorRequest = new CartExtension.CartCalculateCalculatorRequest(cart, request.getOptionalBuyerActionDetails()); @@ -72,6 +104,14 @@ global class CartCalculateSample extends CartExtension.CartCalculate { } } + if (runShippingPromotions) { + shippingPromotions(calculatorRequest); + + if (hasErrorLevelCartValidationOutput(cart.getCartValidationOutputs(), CartExtension.CartValidationOutputTypeEnum.SHIPPING_PROMOTIONS)) { + return; + } + } + if (runPostShipping) { postShipping(calculatorRequest); @@ -103,4 +143,12 @@ global class CartCalculateSample extends CartExtension.CartCalculate { return false; } + + private Boolean isCouponAppliedInCheckout(CartExtension.BuyerActions buyerActions, CartExtension.Cart cart) { + return cart.getStatus() == CartExtension.CartStatusEnum.CHECKOUT && buyerActions.isCouponChanged(); + } + + private Boolean isRecalculationRequestedInCheckout(CartExtension.BuyerActions buyerActions, CartExtension.Cart cart) { + return buyerActions.isRecalculationRequested() && CartExtension.CartStatusEnum.CHECKOUT == cart.getStatus(); + } } \ No newline at end of file diff --git a/commerce/domain/orchestrators/classes/CartCalculateSampleUnitTest.cls b/commerce/domain/orchestrators/classes/CartCalculateSampleUnitTest.cls index 9f2523a..617ac31 100644 --- a/commerce/domain/orchestrators/classes/CartCalculateSampleUnitTest.cls +++ b/commerce/domain/orchestrators/classes/CartCalculateSampleUnitTest.cls @@ -1,13 +1,20 @@ +/* + * Copyright 2023 salesforce.com, inc. + * All Rights Reserved + * Company Confidential + */ + /** * @description A Sample unit test for CartCalculateSample. */ @IsTest -global class CartCalculateSampleUnitTest { +global inherited sharing class CartCalculateSampleUnitTest { private static final String CART_REPRICED = 'CartRepriced'; private static final String PROMOTIONS_RECALCULATED = 'PromotionsRecalculated'; private static final String INVENTORY_CHECKED = 'InventoryChecked'; private static final String SHIPPING_RECALCULATED = 'ShippingRecalculated'; + private static final String SHIPPING_PROMOTIONS_RECALCULATED = 'ShippingPromotionsRecalculated'; private static final String TAXES_RECALCULATED = 'TaxesRecalculated'; private static final String POST_SHIPPING_COMPLETED = 'PostShippingCompleted'; @@ -32,6 +39,25 @@ global class CartCalculateSampleUnitTest { assertExpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED}); assertUnexpectedCalculations(cart, new List{INVENTORY_CHECKED, SHIPPING_RECALCULATED, TAXES_RECALCULATED, POST_SHIPPING_COMPLETED}); } + + @IsTest + public static void shouldRunPricingPromotionsInventoryWhenBuyerAddsToCartInCheckoutState() { + // Arrange Cart + CartExtension.Cart cart = arrangeCart('Checkout'); + + // Arrange BuyerActions and BuyerActionDetails as if the Buyer has added an item to cart + CartExtension.BuyerActionsMock buyerActions = getBuyerActionsForAddToCart(cart); + CartExtension.BuyerActionDetails buyerActionDetails = getBuyerActionDetailsForAddToCart(cart.getCartItems().get(0)); + CartExtension.OptionalBuyerActionDetails optionalBuyerActionDetails = CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails); + + // Act + act(new CartExtension.CartCalculateOrchestratorRequest(cart, buyerActions, optionalBuyerActionDetails)); + + // Assert + assertNoCartValidationOutputs(cart); + assertExpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED, INVENTORY_CHECKED}); + assertUnexpectedCalculations(cart, new List{SHIPPING_RECALCULATED, TAXES_RECALCULATED, POST_SHIPPING_COMPLETED}); + } @IsTest public static void shouldRunPricingAndPromotionsWhenBuyerRemovesItemToCart() { @@ -51,6 +77,25 @@ global class CartCalculateSampleUnitTest { assertExpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED}); assertUnexpectedCalculations(cart, new List{INVENTORY_CHECKED, SHIPPING_RECALCULATED, TAXES_RECALCULATED, POST_SHIPPING_COMPLETED}); } + + @IsTest + public static void shouldRunPricingPromotionsInventoryWhenBuyerRemovesItemToCartInCheckoutState() { + // Arrange Cart + CartExtension.Cart cart = arrangeCart('Checkout'); + + // Arrange BuyerActions and BuyerActionDetails as if the Buyer has added an item to cart + CartExtension.BuyerActionsMock buyerActions = getBuyerActionsForDeleteFromCart(cart); + CartExtension.BuyerActionDetails buyerActionDetails = getBuyerActionDetailsForDeleteFromCart(); + CartExtension.OptionalBuyerActionDetails optionalBuyerActionDetails = CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails); + + // Act + act(new CartExtension.CartCalculateOrchestratorRequest(cart, buyerActions, optionalBuyerActionDetails)); + + // Assert + assertNoCartValidationOutputs(cart); + assertExpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED, INVENTORY_CHECKED}); + assertUnexpectedCalculations(cart, new List{SHIPPING_RECALCULATED, TAXES_RECALCULATED, POST_SHIPPING_COMPLETED}); + } @IsTest public static void shouldRunPricingAndPromotionsWhenBuyerIncreasesQuantityOfItem() { @@ -109,12 +154,32 @@ global class CartCalculateSampleUnitTest { assertUnexpectedCalculations(cart, new List{CART_REPRICED, INVENTORY_CHECKED, SHIPPING_RECALCULATED, POST_SHIPPING_COMPLETED, TAXES_RECALCULATED}); } + @IsTest + public static void shouldRunPromotionsAndShippingAndPostShippingAndTaxesWhenBuyerAddsCouponDuringCheckout() { + // Arrange Cart + CartExtension.Cart cart = arrangeCart('Checkout'); + + // Arrange BuyerActions and BuyerActionDetails as if the Buyer added a coupon at checkout + CartExtension.BuyerActionsMock buyerActions = getBuyerActionsForApplyCouponAtCheckout(cart); + CartExtension.BuyerActionDetails buyerActionDetails = getBuyerActionDetailsForApplyCoupon(cart.getCartAdjustmentBases().get(0)); + CartExtension.OptionalBuyerActionDetails optionalBuyerActionDetails = CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails); + + // Act + act(new CartExtension.CartCalculateOrchestratorRequest(cart, buyerActions, optionalBuyerActionDetails)); + + // Assert + assertNoCartValidationOutputs(cart); + assertExpectedCalculations(cart, new List{PROMOTIONS_RECALCULATED, SHIPPING_RECALCULATED, SHIPPING_PROMOTIONS_RECALCULATED, POST_SHIPPING_COMPLETED, TAXES_RECALCULATED}); + assertUnexpectedCalculations(cart, new List{CART_REPRICED, INVENTORY_CHECKED}); + + } + @IsTest public static void shouldRunPromotionsWhenBuyerRemovesCoupon() { // Arrange Cart CartExtension.Cart cart = arrangeCart(); - // Arrange BuyerActions and BuyerActionDetails as if the Buyer added a coupon + // Arrange BuyerActions and BuyerActionDetails as if the Buyer deleted a coupon CartExtension.BuyerActionsMock buyerActions = getBuyerActionsForDeleteCoupon(cart); CartExtension.BuyerActionDetails buyerActionDetails = getBuyerActionDetailsForDeleteCoupon(); CartExtension.OptionalBuyerActionDetails optionalBuyerActionDetails = CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails); @@ -150,7 +215,7 @@ global class CartCalculateSampleUnitTest { @IsTest public static void shouldRunPricingPromotionsInventoryShippingTaxesWhenRegisteredBuyerStartsCheckoutGivenShippingAddressAvailable() { // Arrange Cart - CartExtension.Cart cart = arrangeCart(); + CartExtension.Cart cart = arrangeCart('Checkout'); // Arrange BuyerActions and BuyerActionDetails as if the Buyer has started Checkout CartExtension.BuyerActionsMock buyerActions = getBuyerActionsForStartCheckoutForBuyerWithShippingAddress(cart); @@ -162,14 +227,57 @@ global class CartCalculateSampleUnitTest { // Assert assertNoCartValidationOutputs(cart); + assertExpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED, INVENTORY_CHECKED, SHIPPING_RECALCULATED, TAXES_RECALCULATED, POST_SHIPPING_COMPLETED}); } + + @IsTest + public static void shouldRunShippingTaxesWhenCartIsInCheckoutStateGivenShippingAddressAvailable() { + // Arrange Cart + CartExtension.Cart cart = arrangeCart('Checkout'); + // Arrange BuyerActions and BuyerActionDetails as if the Buyer has started Checkout + CartExtension.BuyerActionsMock buyerActions = new CartExtension.BuyerActionsMock(cart); + buyerActions.setDeliveryGroupChanged(True); + CartExtension.BuyerActionDetails buyerActionDetails = getBuyerActionDetailsForStartCheckoutForBuyerWithShippingAddress(cart.getCartDeliveryGroups().get(0)); + CartExtension.OptionalBuyerActionDetails optionalBuyerActionDetails = CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails); + + // Act + act(new CartExtension.CartCalculateOrchestratorRequest(cart, buyerActions, optionalBuyerActionDetails)); + + // Assert + assertNoCartValidationOutputs(cart); + + assertExpectedCalculations(cart, new List{SHIPPING_RECALCULATED, TAXES_RECALCULATED, POST_SHIPPING_COMPLETED}); + assertUnexpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED, INVENTORY_CHECKED}); + } + @IsTest - public static void shouldRunShippingTaxesAndPostShippingWhenBuyerUpdatesShippingAddress() { + public static void shouldRunNoCalculatorWhenCartIsInActiveStateGivenAndDeliverMethodSelectedAndShippingAddressAvailable() { // Arrange Cart CartExtension.Cart cart = arrangeCart(); + // Arrange BuyerActions and BuyerActionDetails as if the Buyer has started Checkout + CartExtension.BuyerActionsMock buyerActions = new CartExtension.BuyerActionsMock(cart); + buyerActions.setDeliveryGroupChanged(True); + buyerActions.setDeliveryMethodSelected(True); + CartExtension.BuyerActionDetails buyerActionDetails = getBuyerActionDetailsForStartCheckoutForBuyerWithShippingAddress(cart.getCartDeliveryGroups().get(0)); + CartExtension.OptionalBuyerActionDetails optionalBuyerActionDetails = CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails); + + // Act + act(new CartExtension.CartCalculateOrchestratorRequest(cart, buyerActions, optionalBuyerActionDetails)); + + // Assert + assertNoCartValidationOutputs(cart); + + assertUnexpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED, INVENTORY_CHECKED, SHIPPING_RECALCULATED, TAXES_RECALCULATED, POST_SHIPPING_COMPLETED}); + } + + @IsTest + public static void shouldRunShippingTaxesAndPostShippingWhenBuyerUpdatesShippingAddress() { + // Arrange Cart + CartExtension.Cart cart = arrangeCart('Checkout'); + // Arrange BuyerActions and BuyerActionDetails as if the Buyer has updated their shipping address CartExtension.BuyerActionsMock buyerActions = getBuyerActionsForUpdateCheckoutWithShippingAddress(cart); CartExtension.BuyerActionDetails buyerActionDetails = getBuyerActionDetailsForUpdateCheckoutWithShippingAddress(cart.getCartDeliveryGroups().get(0)); @@ -187,7 +295,7 @@ global class CartCalculateSampleUnitTest { @IsTest public static void shouldRunPostShippingAndTaxesWhenBuyerSelectsDeliveryMethod() { // Arrange Cart - CartExtension.Cart cart = arrangeCart(); + CartExtension.Cart cart = arrangeCart('Checkout'); // Arrange BuyerActions and BuyerActionDetails as if the Buyer selected a delivery method CartExtension.BuyerActionsMock buyerActions = getBuyerActionsForUpdateCheckoutWithSelectedDeliveryMethod(cart); @@ -203,6 +311,43 @@ global class CartCalculateSampleUnitTest { assertUnexpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED, INVENTORY_CHECKED, SHIPPING_RECALCULATED}); } + @IsTest + public static void shouldRunPricingPromotionWhenCartIsActiveAndBuyerRequestsRecalculation() { + // Arrange Cart + CartExtension.Cart cart = arrangeCart('Active'); + + // Arrange BuyerActions for a Cart Recalculation Request + CartExtension.BuyerActionsMock buyerActions = getBuyerActionsForRecalculationRequest(cart); + CartExtension.BuyerActionDetails buyerActionDetails = getBuyerActionDetailsForRecalculationRequest(); + CartExtension.OptionalBuyerActionDetails optionalBuyerActionDetails = CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails); + + // Act + act(new CartExtension.CartCalculateOrchestratorRequest(cart, buyerActions, optionalBuyerActionDetails)); + + // Assert + assertNoCartValidationOutputs(cart); + assertExpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED}); + assertUnexpectedCalculations(cart, new List{INVENTORY_CHECKED, SHIPPING_RECALCULATED, POST_SHIPPING_COMPLETED, TAXES_RECALCULATED}); + } + + @IsTest + public static void shouldRunPricingPromotionInventoryShippingPostShippingTaxesWhenCartIsInCheckoutAndBuyerRequestsRecalculation() { + // Arrange Cart + CartExtension.Cart cart = arrangeCart('Checkout'); + + // Arrange BuyerActions for a Cart Recalculation Request + CartExtension.BuyerActionsMock buyerActions = getBuyerActionsForRecalculationRequest(cart); + CartExtension.BuyerActionDetails buyerActionDetails = getBuyerActionDetailsForRecalculationRequest(); + CartExtension.OptionalBuyerActionDetails optionalBuyerActionDetails = CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails); + + // Act + act(new CartExtension.CartCalculateOrchestratorRequest(cart, buyerActions, optionalBuyerActionDetails)); + + // Assert + assertNoCartValidationOutputs(cart); + assertExpectedCalculations(cart, new List{CART_REPRICED, PROMOTIONS_RECALCULATED, INVENTORY_CHECKED, SHIPPING_RECALCULATED, POST_SHIPPING_COMPLETED, TAXES_RECALCULATED}); + } + private static CartExtension.Cart arrangeCart() { Account testAccount = new Account(Name='My Account'); insert testAccount; @@ -225,6 +370,28 @@ global class CartCalculateSampleUnitTest { return CartExtension.CartTestUtil.getCart(testCart.Id); } + private static CartExtension.Cart arrangeCart(String cartStatus) { + Account testAccount = new Account(Name='My Account'); + insert testAccount; + + WebStore testWebStore = new WebStore(Name='My WebStore'); + insert testWebStore; + + WebCart testCart = new WebCart(Name='My Cart', WebStoreId=testWebStore.Id, AccountId=testAccount.Id, Status=cartStatus); + insert testCart; + + CartDeliveryGroup testDeliveryGroup = new CartDeliveryGroup(Name='My Delivery Group', CartId=testCart.Id); + insert testDeliveryGroup; + + CartItem testCartItem = new CartItem(Name='My Cart Item', CartId=testCart.Id, CartDeliveryGroupId=testDeliveryGroup.Id); + insert testCartItem; + + WebCartAdjustmentBasis testCartAdjustmentBasis = new WebCartAdjustmentBasis(Name='My Coupon', WebCartId=testCart.Id); + insert testCartAdjustmentBasis; + + return CartExtension.CartTestUtil.getCart(testCart.Id); + } + private static void act(CartExtension.CartCalculateOrchestratorRequest request) { Test.startTest(); cartCalculateSample.calculate(request); @@ -278,6 +445,11 @@ global class CartCalculateSampleUnitTest { cart.setName(cart.getName() + ', ' + SHIPPING_RECALCULATED); } + global override void shippingPromotions(CartExtension.CartCalculateCalculatorRequest request) { + CartExtension.Cart cart = request.getCart(); + cart.setName(cart.getName() + ', ' + SHIPPING_PROMOTIONS_RECALCULATED); + } + global override void tax(CartExtension.CartCalculateCalculatorRequest request) { CartExtension.Cart cart = request.getCart(); cart.setName(cart.getName() + ', ' + TAXES_RECALCULATED); @@ -357,6 +529,10 @@ global class CartCalculateSampleUnitTest { return getCouponChangedBuyerActions(cart); } + private static CartExtension.BuyerActionsMock getBuyerActionsForApplyCouponAtCheckout(CartExtension.Cart cart) { + return getCouponChangedAtCheckoutBuyerActions(cart); + } + private static CartExtension.BuyerActionDetails getBuyerActionDetailsForApplyCoupon(CartExtension.CartAdjustmentBasis cartAdjustmentBasis) { CartExtension.CouponChange couponChange = new CartExtension.CouponChange.Builder() .withChangedAdjustmentBasis(CartExtension.OptionalCartAdjustmentBasis.of(cartAdjustmentBasis)) @@ -385,6 +561,12 @@ global class CartCalculateSampleUnitTest { return buyerActionDetails; } + private static CartExtension.BuyerActionsMock getBuyerActionsForRecalculationRequest(CartExtension.Cart cart) { + CartExtension.BuyerActionsMock buyerActions = new CartExtension.BuyerActionsMock(cart); + buyerActions.setRecalculationRequested(True); + return buyerActions; + } + private static CartExtension.BuyerActionsMock getBuyerActionsForStartCheckout(CartExtension.Cart cart) { CartExtension.BuyerActionsMock buyerActions = new CartExtension.BuyerActionsMock(cart); buyerActions.setCheckoutStarted(True); @@ -451,6 +633,12 @@ global class CartCalculateSampleUnitTest { return buyerActionDetails; } + private static CartExtension.BuyerActionDetails getBuyerActionDetailsForRecalculationRequest() { + CartExtension.BuyerActionDetails buyerActionDetails = new CartExtension.BuyerActionDetails.Builder() + .build(); + return buyerActionDetails; + } + private static CartExtension.BuyerActionsMock getCartItemChangedBuyerActions(CartExtension.Cart cart) { CartExtension.BuyerActionsMock buyerActions = new CartExtension.BuyerActionsMock(cart); buyerActions.setCartItemChanged(True); @@ -462,4 +650,10 @@ global class CartCalculateSampleUnitTest { buyerActions.setCouponChanged(True); return buyerActions; } + + private static CartExtension.BuyerActionsMock getCouponChangedAtCheckoutBuyerActions(CartExtension.Cart cart) { + CartExtension.BuyerActionsMock buyerActions = new CartExtension.BuyerActionsMock(cart); + buyerActions.setCouponChanged(True); + return buyerActions; + } } \ No newline at end of file diff --git a/commerce/domain/orchestrators/classes/ShippingAndTaxesForCartOrchestrator.cls b/commerce/domain/orchestrators/classes/ShippingAndTaxesForCartOrchestrator.cls index f3d1c68..e8049eb 100644 --- a/commerce/domain/orchestrators/classes/ShippingAndTaxesForCartOrchestrator.cls +++ b/commerce/domain/orchestrators/classes/ShippingAndTaxesForCartOrchestrator.cls @@ -8,7 +8,7 @@ * Calculates shipping, post shipping and taxes for update shipping address operation. * Calculates taxes for select delivery method operation. */ -global class ShippingAndTaxesForCartOrchestrator extends CartExtension.CartCalculate { +global with sharing class ShippingAndTaxesForCartOrchestrator extends CartExtension.CartCalculate { /** * @description All classes extending CartExtension.CartCalculate must have a default constructor defined diff --git a/commerce/domain/orchestrators/classes/ShippingAndTaxesForCartOrchestratorTest.cls b/commerce/domain/orchestrators/classes/ShippingAndTaxesForCartOrchestratorTest.cls index 5b51b97..82c0267 100644 --- a/commerce/domain/orchestrators/classes/ShippingAndTaxesForCartOrchestratorTest.cls +++ b/commerce/domain/orchestrators/classes/ShippingAndTaxesForCartOrchestratorTest.cls @@ -2,7 +2,7 @@ * @description A Sample unit test for ShippingAndTaxesForCartOrchestrator. */ @IsTest -global class ShippingAndTaxesForCartOrchestratorTest { +global inherited sharing class ShippingAndTaxesForCartOrchestratorTest { private static final String CART_REPRICED = 'CartRepriced'; private static final String PROMOTIONS_RECALCULATED = 'PromotionsRecalculated'; diff --git a/commerce/domain/pricing/cart/PricingBasicCalculator/PricingCalculatorSample.cls b/commerce/domain/pricing/cart/PricingBasicCalculator/PricingCalculatorSample.cls index d51e29a..670386a 100644 --- a/commerce/domain/pricing/cart/PricingBasicCalculator/PricingCalculatorSample.cls +++ b/commerce/domain/pricing/cart/PricingBasicCalculator/PricingCalculatorSample.cls @@ -47,6 +47,7 @@ public class PricingCalculatorSample extends CartExtension.PricingCartCalculator CartExtension.CartValidationOutputLevelEnum.ERROR); cvo.setMessage('We are not able to process your cart. Please contact support.'); cart.getCartValidationOutputs().add(cvo); + System.debug('No Pricing data received'); return; } applyPricesToCartItems(cart, cartItems.iterator(), pricingDataMap); @@ -55,7 +56,8 @@ public class PricingCalculatorSample extends CartExtension.PricingCartCalculator // This is an example of throwing special type of Exception (CartCalculateRuntimeException). // Throwing this exception causes the rollback of all previously applied changes to the cart (in scope of given request) // and may not always be the best choice. - throw new CartExtension.CartCalculateRuntimeException('An integration error occurred in COMPUTE_PRICES. Contact your admin.'); + System.debug('Exception occurred, message:' + e.getMessage() + ', stacktrace: ' + e.getStackTraceString()); + throw new CartExtension.CartCalculateRuntimeException('An integration error occurred in COMPUTE_PRICES. Contact your admin', e); } } @@ -151,6 +153,7 @@ public class PricingCalculatorSample extends CartExtension.PricingCartCalculator cartItem); cvo.setMessage('No price available for the SKU in the Cart.'); cart.getCartValidationOutputs().add(cvo); + System.debug('No price available for the SKU: ' + cartItem.getSku()); continue; } setPricingFieldsOnCart(cartItem, lineItemIdToPricingDetailsMap.get(cartItem.getSku())); @@ -195,6 +198,7 @@ public class PricingCalculatorSample extends CartExtension.PricingCartCalculator if (r.getStatusCode() != 200) { // return null in case of not successful response from 3rd party service + System.debug('Did not receive pricing data. Call to external service was not successful.'); return null; } diff --git a/commerce/domain/pricing/cart/calculator/EstimatedActualPricingCalculator.cls b/commerce/domain/pricing/cart/calculator/EstimatedActualPricingCalculator.cls index 7e2974f..4e37afd 100644 --- a/commerce/domain/pricing/cart/calculator/EstimatedActualPricingCalculator.cls +++ b/commerce/domain/pricing/cart/calculator/EstimatedActualPricingCalculator.cls @@ -45,55 +45,62 @@ public class EstimatedActualPricingCalculator extends CartExtension.PricingCartC } public virtual override void calculate(CartExtension.CartCalculateCalculatorRequest request) { - CartExtension.Cart cart = request.getCart(); + try { + CartExtension.Cart cart = request.getCart(); - if (cart.getStatus() == CartExtension.CartStatusEnum.ACTIVE) { - super.calculate(request); - return; - } - - Iterator cartItemsIterator = clearErrorsAndGetCartItemsIterator(cart, request.getOptionalBuyerActionDetails()); - - // Get the SKUs from each cart item that needs price calculations - Map skuToCartItem = new Map(); - while (cartItemsIterator.hasNext()) { - CartExtension.CartItem cartItem = cartItemsIterator.next(); - skuToCartItem.put(cartItem.getSku(), cartItem); - } - - Map pricingDataMap = getPricingDataFromExternalServiceForSkus(skuToCartItem.keySet()); - if (pricingDataMap == Null) { - // No data returned means there is an issue with underlying 3rd party service. Populate generic error message for the Buyer. - CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput( - CartExtension.CartValidationOutputTypeEnum.SHIPPING, - CartExtension.CartValidationOutputLevelEnum.ERROR); - String errorMessage = getGenericErrorMessage(); - cvo.setMessage(errorMessage); - cart.getCartValidationOutputs().add(cvo); - return; - } - - cartItemsIterator = skuToCartItem.values().iterator(); - while (cartItemsIterator.hasNext()) { - CartExtension.CartItem cartItem = cartItemsIterator.next(); - if (!pricingDataMap.containsKey(cartItem.getSku())) { - // No price available for the SKU in the Cart. Populate error message for the Buyer. + if (cart.getStatus() == CartExtension.CartStatusEnum.ACTIVE) { + super.calculate(request); + return; + } + + Iterator cartItemsIterator = clearErrorsAndGetCartItemsIterator(cart, request.getOptionalBuyerActionDetails()); + + // Get the SKUs from each cart item that needs price calculations + Map skuToCartItem = new Map(); + while (cartItemsIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemsIterator.next(); + skuToCartItem.put(cartItem.getSku(), cartItem); + } + + Map pricingDataMap = getPricingDataFromExternalServiceForSkus(skuToCartItem.keySet()); + if (pricingDataMap == Null) { + // No data returned means there is an issue with underlying 3rd party service. Populate generic error message for the Buyer. CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput( - CartExtension.CartValidationOutputTypeEnum.PRICING, - CartExtension.CartValidationOutputLevelEnum.ERROR, - cartItem); - String errorMessage = getFailedToRepriceItemMessage(cartItem); + CartExtension.CartValidationOutputTypeEnum.PRICING, + CartExtension.CartValidationOutputLevelEnum.ERROR); + String errorMessage = getGenericErrorMessage(); cvo.setMessage(errorMessage); cart.getCartValidationOutputs().add(cvo); - continue; + System.debug('No Pricing data received'); + return; } - Decimal price = pricingDataMap.get(cartItem.getSku()); - // Update cart item fields - cartItem.setListPrice(price); - cartItem.setSalesPrice(price); - cartItem.setTotalListPrice(price * cartItem.getQuantity()); - cartItem.setTotalPrice(price * cartItem.getQuantity()); + + cartItemsIterator = skuToCartItem.values().iterator(); + while (cartItemsIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemsIterator.next(); + if (!pricingDataMap.containsKey(cartItem.getSku())) { + // No price available for the SKU in the Cart. Populate error message for the Buyer. + CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput( + CartExtension.CartValidationOutputTypeEnum.PRICING, + CartExtension.CartValidationOutputLevelEnum.ERROR, + cartItem); + String errorMessage = getFailedToRepriceItemMessage(cartItem); + cvo.setMessage(errorMessage); + cart.getCartValidationOutputs().add(cvo); + continue; + } + Decimal price = pricingDataMap.get(cartItem.getSku()); + // Update cart item fields + cartItem.setListPrice(price); + cartItem.setSalesPrice(price); + cartItem.setTotalListPrice(price * cartItem.getQuantity()); + cartItem.setTotalPrice(price * cartItem.getQuantity()); + } + } catch (Exception e) { + System.debug('Exception occurred, message:' + e.getMessage() + ', stacktrace: ' + e.getStackTraceString()); + throw e; } + } /** @@ -220,6 +227,7 @@ public class EstimatedActualPricingCalculator extends CartExtension.PricingCartC if (r.getStatusCode() != 200) { // return null in case of not successful response from 3rd party service + System.debug('Did not receive pricing data. Call to external service was not successful.'); return null; } diff --git a/commerce/domain/pricing/service/classes/PricingServiceSample.cls b/commerce/domain/pricing/service/classes/PricingServiceSample.cls index 6364d57..450f653 100644 --- a/commerce/domain/pricing/service/classes/PricingServiceSample.cls +++ b/commerce/domain/pricing/service/classes/PricingServiceSample.cls @@ -100,6 +100,9 @@ public class PricingServiceSample extends commercestorepricing.PricingService { // amount, total adjustment amount and total amount. Item level - line id, product id, unit price, // list price, unit pricebook entry id, unit adjustment amount, total line amount, total adjustment // amount, total price, and total list price. + // + // Caution: If you're overriding fields containing Salesforce IDs, ensure that they are valid IDs. Otherwise, + // subsequent operations may fail unexpectedly. public override commercestorepricing.TransactionalPricingResponse processTransactionalPrice( commercestorepricing.TransactionalPricingRequest request2 ) { @@ -123,7 +126,6 @@ public class PricingServiceSample extends commercestorepricing.PricingService { commercestorepricing.TxnPricingResponseItemCollection txnItemCollection = txnResponse.getTxnPricingResponseItems(); for (Integer j = 0; j < txnItemCollection.size(); j++) { commercestorepricing.TransactionalPricingResponseItem txnItem = txnItemCollection.get(j); - txnItem.setLineId(appendField(prefix, txnItem.getLineId())); txnItem.setProductId(appendField(prefix, txnItem.getProductId())); txnItem.setUnitPricePriceBookEntryId( appendField(prefix, txnItem.getUnitPricePriceBookEntryId()) @@ -139,13 +141,18 @@ public class PricingServiceSample extends commercestorepricing.PricingService { txnResponse.getTotalProductAmount() + txnResponse.getTotalAdjustmentAmount() ); - if (!txnItemCollection.isEmpty()) { - // Override success/failure of a product easily by adding an error message to the product. Here - // we are failing the first product in the response. - String customErrorMessage = 'We no longer sell this particular product.'; - String localizedErrorMessage = 'Wir verkaufen dieses spezielle Produkt nicht mehr.'; - txnItemCollection.get(0).setError(customErrorMessage, localizedErrorMessage); - } + /** + * Override success/failure of a product easily by adding an error message to the product. + * Here is a sample code demonstrating how to set error messages for products. + * We are failing the first product in the response. + * Warning: Uncommenting the code below will cause cart operations to fail. + */ + // if (!txnItemCollection.isEmpty()) { + // String customErrorMessage = 'We no longer sell this particular product.'; + // String localizedErrorMessage = 'Wir verkaufen dieses spezielle Produkt nicht mehr.'; + // txnItemCollection.get(0).setError(customErrorMessage, localizedErrorMessage); + // } + return txnResponse; } diff --git a/commerce/domain/promotion/cart/calculator/classes/PromotionCalculatorSample.cls b/commerce/domain/promotion/cart/calculator/classes/PromotionCalculatorSample.cls index 4f20c07..314dd86 100644 --- a/commerce/domain/promotion/cart/calculator/classes/PromotionCalculatorSample.cls +++ b/commerce/domain/promotion/cart/calculator/classes/PromotionCalculatorSample.cls @@ -1,24 +1,66 @@ - /** +/** * @description This sample is for the situations where Promotion Calculation needs to be extended or overridden via the * extension point for the Promotion Calculator. You are expected to refer this and write your own implementation. * This class must extend the CartExtension.PromotionsCartCalculator class to be processed. + * + * In this example cart items are evaluated against a BOGO (Buy X, Get Y) promotion > Buy 5 items of qualifying product X, + * get $2 off a unit of target Y. */ public with sharing class PromotionCalculatorSample extends CartExtension.PromotionsCartCalculator { - // You MUST change this to be a valid promotion id. - public static final String DUMMY_PROMOTION_ID = '0c8xx000000003FAAQ'; + // You MUST change following to be a valid promotion, product ids. + public static final String DUMMY_PROMOTION_ID = '0c8xx00000004JlAAI'; + private static final String QUALIFIER_PRODUCT_ID = '01txx0000006lmmAAA'; + private static final String TARGET_PRODUCT_ID = '01txx0000006lmuAAA'; + private static final Integer PROMOTION_ADJUSTMENT = -2; + private static final Integer QUALIFIER_QUANTITY = 5; public virtual override void calculate(CartExtension.CartCalculateCalculatorRequest request) { - cartextension.Cart cart = request.getCart(); - resetAllAdjustments(cart); - applyAdjustments(cart); + validateBuyerActionDetailsAndEvaluatePromotion(request.getCart(), request.getOptionalBuyerActionDetails()); + } + + /** + * @description Evaluate promotion for cart when OptionalBuyerActionDetails not present or + * OptionalBuyerActionDetails includes qualifying/target product. + * @param cart In memory representation of the Cart + * @param optionalBuyerActionDetails The latest set of changes applied to the Cart by the Buyer + */ + private void validateBuyerActionDetailsAndEvaluatePromotion(cartextension.Cart cart, cartextension.OptionalBuyerActionDetails optionalBuyerActionDetails) { + Iterator quoteCheckIter = cart.getCartItems().iterator(); + while (quoteCheckIter.hasNext()) { + CartExtension.CartItem quoteCheckItem = quoteCheckIter.next(); + if (quoteCheckItem.getQuoteLineItemId() != null) { + resetAllAdjustments(cart); + return; + } + } + + if (!optionalBuyerActionDetails.isPresent() || optionalBuyerActionDetails.get().isCheckoutStarted()) { + resetAllAdjustments(cart); + evaluatePromotionForCartItems(cart); + return; + } + + List cartItemChanges = optionalBuyerActionDetails.get().getCartItemChanges(); + for (CartExtension.CartItemChange cartItemChange : cartItemChanges) { + CartExtension.OptionalCartItem optionalCartItem = cartItemChange.getChangedItem(); + if (optionalCartItem.isPresent()) { + CartExtension.CartItem cartItem = optionalCartItem.get(); + if (cartItem.getProduct2Id() == ID.valueOf(QUALIFIER_PRODUCT_ID) || + cartItem.getProduct2Id() == ID.valueOf(TARGET_PRODUCT_ID)) { + resetAllAdjustments(cart); + evaluatePromotionForCartItems(cart); + break; + } + } + } } /** * @description Remove cart & cart-item level adjustments, cart validation outputs. * @param cart Holds details about cart */ - public static void resetAllAdjustments(cartextension.Cart cart) { + private static void resetAllAdjustments(cartextension.Cart cart) { // Remove all cart-level adjustments Iterator cagIter = cart.getCartAdjustmentGroups().iterator(); @@ -32,11 +74,11 @@ public with sharing class PromotionCalculatorSample extends CartExtension.Promot } // Remove all cart-item level adjustments - Iterator ciIter = cart.getCartItems().iterator(); - while(ciIter.hasNext()) { + Iterator cartItemIterator = cart.getCartItems().iterator(); + while(cartItemIterator.hasNext()) { // For every cart item, cursor through adjustments - CartExtension.CartItem ci = ciIter.next(); + CartExtension.CartItem ci = cartItemIterator.next(); Iterator ciaIter = ci.getCartItemPriceAdjustments().iterator(); List ciaToRemove= new List(); @@ -45,7 +87,7 @@ public with sharing class PromotionCalculatorSample extends CartExtension.Promot ciaToRemove.add(ciaIter.next()); } for(CartExtension.CartItemPriceAdjustment cia : ciaToRemove) { - ci.getCartItemPriceAdjustments().remove(cia); + ci.getCartItemPriceAdjustments().remove(cia); } } @@ -63,36 +105,79 @@ public with sharing class PromotionCalculatorSample extends CartExtension.Promot } + /** - * @description Apply flat 5 percent discount across all cart items + * @description Evaluate BOGO promotion (Buy 5 items of qualifying product, get $2 off a unit of target product, + * for cart items and apply adjustments. * @param cart Holds details about cart */ - public static void applyAdjustments(CartExtension.Cart cart) { - - Decimal pctDiscount = -5; - Iterator ciIter = cart.getCartItems().iterator(); - while(ciIter.hasNext()) { - - CartExtension.CartItem ci = ciIter.next(); - Decimal promotionAdjustment = (ci.getSalesPrice() * (pctDiscount/100) * ci.getQuantity()); - promotionAdjustment = promotionAdjustment.setScale(2,System.RoundingMode.HALF_DOWN); // Currency precision rounding - CartExtension.CartItemPriceAdjustment cia = new - CartExtension.CartItemPriceAdjustment(cartextension.CartAdjustmentTargetTypeEnum.ITEM, // AdjustmentTargetType - promotionAdjustment, // TotalAmount - cartextension.PriceAdjustmentSourceEnum.PROMOTION, // AdjustmentSource - cartextension.AdjustmentTypeEnum.ADJUSTMENT_PERCENTAGE, // AdjustmentType - pctDiscount, // AdjustmentValue - DUMMY_PROMOTION_ID); // PriceAdjustmentCauseId - cia.setPriority(1); - cia.setAdjustmentAmountScope(cartextension.AdjustmentAmountScopeEnum.TOTAL); - cia.setDescription('PromotionCalculator'); - ci.getCartItemPriceAdjustments().add(cia); - - // Populate TotalPromoAdjustmentAmount for cart-item & update totals based on promotion adjustment - ci.setTotalPromoAdjustmentAmount(promotionAdjustment); - ci.setTotalAdjustmentAmount(promotionAdjustment); - ci.setTotalPriceAfterAllAdjustments(ci.getSalesPrice() - promotionAdjustment); + private static void evaluatePromotionForCartItems(CartExtension.Cart cart) { + Integer targetCount = 0; + Integer qualifierCount = 0; + Integer adjustmentCount = 0; + Iterator cartItemIterator = cart.getCartItems().iterator(); + + // If cartItems size is greater than 0, get qualifier, target count & apply adjustments + if(cartItemIterator.hasNext()) { + qualifierCount = getProductCount(cart, QUALIFIER_PRODUCT_ID); + targetCount = getProductCount(cart, TARGET_PRODUCT_ID); + adjustmentCount = qualifierCount / QUALIFIER_QUANTITY; + adjustmentCount = Math.min(adjustmentCount, targetCount); + applyAdjustments(cart, adjustmentCount); + } + } + /** + * @description Helper method to get count of given product in cart. + * @param cart Holds details about cart + * @param productId Product Id + */ + private static Integer getProductCount(CartExtension.Cart cart, String productId) { + Iterator cartItemIterator = cart.getCartItems().iterator(); + while(cartItemIterator.hasNext()) { + CartExtension.CartItem ci = cartItemIterator.next(); + if (ci.getProduct2Id() == ID.valueOf(productId)) { + return ci.getQuantity().intValue(); + } + } + return 0; + } + + /** + * @description Apply $2 off discount on given number of target units + * @param cart Holds details about cart + * @param adjustmentCount Number of target units the discount applies to + */ + private static void applyAdjustments(CartExtension.Cart cart, Integer adjustmentCount) { + + Iterator cartItemIterator = cart.getCartItems().iterator(); + while(cartItemIterator.hasNext() && adjustmentCount > 0) { + + CartExtension.CartItem ci = cartItemIterator.next(); + if(ci.getProduct2Id().equals(TARGET_PRODUCT_ID)) { + Decimal totalPromotionAdjustment = adjustmentCount * PROMOTION_ADJUSTMENT; + CartExtension.CartItemPriceAdjustment cia = new + CartExtension.CartItemPriceAdjustment(cartextension.CartAdjustmentTargetTypeEnum.ITEM, // AdjustmentTargetType + totalPromotionAdjustment, // TotalAmount + cartextension.PriceAdjustmentSourceEnum.PROMOTION, // AdjustmentSource + cartextension.AdjustmentTypeEnum.ADJUSTMENT_AMOUNT, // AdjustmentType + PROMOTION_ADJUSTMENT, // AdjustmentValue + DUMMY_PROMOTION_ID); // PriceAdjustmentCauseId + Decimal totalLineAmount = (ci.getTotalLineAmount() == null) ? + (ci.getSalesPrice() * ci.getQuantity()) : ci.getTotalLineAmount(); + + cia.setPriority(1); + cia.setAdjustmentAmountScope(cartextension.AdjustmentAmountScopeEnum.TOTAL); + cia.setDescription('PromotionCalculator'); + ci.getCartItemPriceAdjustments().add(cia); + + // Populate TotalPromoAdjustmentAmount for cart-item & update totals based on promotion adjustment + ci.setTotalPromoAdjustmentAmount(totalPromotionAdjustment); + ci.setTotalAdjustmentAmount(totalPromotionAdjustment); + ci.setTotalPriceAfterAllAdjustments(totalLineAmount + totalPromotionAdjustment); + } else { + continue; + } } } } \ No newline at end of file diff --git a/commerce/domain/promotion/cart/calculator/classes/PromotionCalculatorSampleTest.cls b/commerce/domain/promotion/cart/calculator/classes/PromotionCalculatorSampleTest.cls new file mode 100644 index 0000000..f8deb0d --- /dev/null +++ b/commerce/domain/promotion/cart/calculator/classes/PromotionCalculatorSampleTest.cls @@ -0,0 +1,280 @@ +/** + * @description Sample unit test for PromotionCalculatorSample. + */ +@IsTest +global class PromotionCalculatorSampleTest { + private static final String CART_NAME = 'My Cart'; + private static final String ACCOUNT_NAME = 'My Account'; + private static final String WEBSTORE_NAME = 'My WebStore'; + private static final String DELIVERYGROUP_NAME = 'Default Delivery Group'; + private static final String CART_ITEM1_NAME = 'My Cart Item 1'; + private static final String CART_ITEM2_NAME = 'My Cart Item 2'; + private static final String TARGET_PRODUCT_ID = '01txx0000006lmuAAA'; + private static final String QUALIFIER_PRODUCT_ID = '01txx0000006lmmAAA'; + private static final Decimal TARGET_SALES_PRICE = 10.00; + private static final Decimal QUALIFIER_SALES_PRICE = 20.00; + private static final Decimal DISCOUNT_VALUE = -2.0; + private static final Decimal TOTAL_ADJUSTMENT_AMOUNT = -4.0; + + private static final PromotionCalculatorSample promotionCalculator = new PromotionCalculatorSample(); + + /** + * @description Verify Promotion is correctly applied on cart item. + */ + @IsTest + public static void testPromotionAndPriceAdjustments_WithQualifierAndTargetItems() { + // Arrange + // Create a Cart with CHECKOUT status + Id cartId = createCartWithSpecifiedStatus(CartExtension.CartStatusEnum.CHECKOUT); + + // Associate qualifying & target items to the Cart and load the cart + CartExtension.Cart cart = addItemsToCart(cartId, 10, 3); + + // Arrange buyer updated cart item with target + List changedCartItems = new List(); + Iterator cartItemsIterator = cart.getCartItems().iterator(); + while (cartItemsIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemsIterator.next(); + if (cartItem.getProduct2Id() == ID.valueOf(TARGET_PRODUCT_ID)) { + changedCartItems.add( + new CartExtension.CartItemChange.Builder() + .withChangedItem(CartExtension.OptionalCartItem.of(cartItem)) + .withAdded(true) + .build()); + } + } + CartExtension.BuyerActionDetails buyerActionDetails = new CartExtension.BuyerActionDetails.Builder() + .withCartItemChanges(changedCartItems).build(); + + // Act + Test.startTest(); + promotionCalculator.calculate(new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails))); + Test.stopTest(); + + // Assert + // Verify that we have 2 CartItems + Assert.areEqual(2, cart.getCartItems().size()); + + // Verify that the CartItem has 1 price adjustment with correct adjustment type and value + Assert.areEqual(1, cart.getCartItems().get(1).getCartItemPriceAdjustments().size()); + Assert.areEqual(Cartextension.AdjustmentTypeEnum.ADJUSTMENT_AMOUNT, cart.getCartItems().get(1).getCartItemPriceAdjustments().get(0).getAdjustmentType()); + Assert.areEqual(DISCOUNT_VALUE, cart.getCartItems().get(1).getCartItemPriceAdjustments().get(0).getAdjustmentValue()); + Assert.areEqual(TOTAL_ADJUSTMENT_AMOUNT, cart.getCartItems().get(1).getCartItemPriceAdjustments().get(0).getTotalAmount()); + + // Verify CartItem adjustment and total price + Assert.areEqual(TOTAL_ADJUSTMENT_AMOUNT, cart.getCartItems().get(1).getTotalPromoAdjustmentAmount()); + Assert.areEqual(TOTAL_ADJUSTMENT_AMOUNT, cart.getCartItems().get(1).getTotalAdjustmentAmount()); + Assert.areEqual(((TARGET_SALES_PRICE * 3) + TOTAL_ADJUSTMENT_AMOUNT), cart.getCartItems().get(1).getTotalPriceAfterAllAdjustments()); + } + + /** + * @description Verify Promotion is not applied when cart does not include enough qualifying or target items. + */ + @IsTest + public static void testPromotionAndPriceAdjustments_WithInsufficientQualifierAndTargetItems() { + // Arrange + // Create a Cart with CHECKOUT status + Id cartId = createCartWithSpecifiedStatus(CartExtension.CartStatusEnum.CHECKOUT); + + // Associate qualifying & target items to the Cart and load the cart + CartExtension.Cart cart = addItemsToCart(cartId, 4, 1); + + // Arrange buyer updated cart item with target + List changedCartItems = new List(); + Iterator cartItemsIterator = cart.getCartItems().iterator(); + while (cartItemsIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemsIterator.next(); + if (cartItem.getProduct2Id() == ID.valueOf(TARGET_PRODUCT_ID)) { + changedCartItems.add( + new CartExtension.CartItemChange.Builder() + .withChangedItem(CartExtension.OptionalCartItem.of(cartItem)) + .withAdded(true) + .build()); + } + } + CartExtension.BuyerActionDetails buyerActionDetails = new CartExtension.BuyerActionDetails.Builder() + .withCartItemChanges(changedCartItems).build(); + + + // Act + Test.startTest(); + promotionCalculator.calculate(new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.of(buyerActionDetails))); + Test.stopTest(); + + // Assert + // Verify that we have 2 CartItems + Assert.areEqual(2, cart.getCartItems().size()); + + // Verify that the CartItem has 0 price adjustments. + Assert.areEqual(0, cart.getCartItems().get(0).getCartItemPriceAdjustments().size()); + Assert.areEqual(0, cart.getCartItems().get(1).getCartItemPriceAdjustments().size()); + } + + /** + * @description Verify Promotion is correctly applied on cart item w/o optional buyer action details. + */ + @IsTest + public static void testPromotionAndPriceAdjustments_WithOutBuyerActionDetails() { + // Arrange + // Create a Cart with CHECKOUT status + Id cartId = createCartWithSpecifiedStatus(CartExtension.CartStatusEnum.CHECKOUT); + + // Associate qualifying & target items to the Cart and load the cart + CartExtension.Cart cart = addItemsToCart(cartId, 5, 2); + + // Act + Test.startTest(); + promotionCalculator.calculate(new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty())); + Test.stopTest(); + + // Assert + // Verify that we have 2 CartItems + Assert.areEqual(2, cart.getCartItems().size()); + + // Verify that the CartItem has 1 price adjustment with correct adjustment type and value + Assert.areEqual(1, cart.getCartItems().get(1).getCartItemPriceAdjustments().size()); + Assert.areEqual(Cartextension.AdjustmentTypeEnum.ADJUSTMENT_AMOUNT, cart.getCartItems().get(1).getCartItemPriceAdjustments().get(0).getAdjustmentType()); + Assert.areEqual(DISCOUNT_VALUE, cart.getCartItems().get(1).getCartItemPriceAdjustments().get(0).getAdjustmentValue()); + Assert.areEqual(DISCOUNT_VALUE, cart.getCartItems().get(1).getCartItemPriceAdjustments().get(0).getTotalAmount()); + + // Verify CartItem adjustment and total price + Assert.areEqual(DISCOUNT_VALUE, cart.getCartItems().get(1).getTotalPromoAdjustmentAmount()); + Assert.areEqual(DISCOUNT_VALUE, cart.getCartItems().get(1).getTotalAdjustmentAmount()); + Assert.areEqual(((TARGET_SALES_PRICE * 2) + DISCOUNT_VALUE), cart.getCartItems().get(1).getTotalPriceAfterAllAdjustments()); + } + + /** + * @description Verify promotion is skipped and adjustments are reset when a cart item is associated with a QuoteLineItem. + */ + @IsTest + public static void testPromotionSkipped_WhenCartItemHasQuoteLineItem() { + // Arrange - quantities that would normally trigger the BOGO promotion (10 qualifiers, 3 targets) + Id cartId = createCartWithSpecifiedStatus(CartExtension.CartStatusEnum.CHECKOUT); + CartExtension.Cart cart = addItemsToCart(cartId, 10, 3); + + // Verify promotion is applied before QuoteLineItem association + promotionCalculator.calculate( + new CartExtension.CartCalculateCalculatorRequest( + cart, CartExtension.OptionalBuyerActionDetails.empty())); + Assert.areEqual(1, cart.getCartItems().get(1).getCartItemPriceAdjustments().size(), + 'Promotion should be applied before QuoteLineItem association'); + + // Associate a QuoteLineItem with the first cart item and reload + Id quoteLineItemId = createQuoteLineItem(cartId); + update new CartItem(Id = cart.getCartItems().get(0).getId(), QuoteLineItemId = quoteLineItemId); + cart = CartExtension.CartTestUtil.getCart(cartId); + + // Act - calculate again with QuoteLineItem now associated + Test.startTest(); + promotionCalculator.calculate( + new CartExtension.CartCalculateCalculatorRequest( + cart, CartExtension.OptionalBuyerActionDetails.empty())); + Test.stopTest(); + + // Assert - no adjustments applied because the quote guard fired before promotion evaluation + Assert.areEqual(2, cart.getCartItems().size()); + Assert.areEqual(0, cart.getCartItems().get(0).getCartItemPriceAdjustments().size(), + 'Qualifying item should have no adjustments when quote guard fires'); + Assert.areEqual(0, cart.getCartItems().get(1).getCartItemPriceAdjustments().size(), + 'Target item should have no adjustments when quote guard fires'); + } + + /** + * @description Creates the minimum Quote hierarchy needed to produce a real QuoteLineItem ID. + * @param cartId ID of the WebCart, used to look up the associated Account + * @return ID of the inserted QuoteLineItem + */ + private static Id createQuoteLineItem(Id cartId) { + Id accountId = [SELECT AccountId FROM WebCart WHERE Id = :cartId].AccountId; + + Product2 product = new Product2(Name = 'Quote Product', IsActive = true); + insert product; + + Id standardPricebookId = Test.getStandardPricebookId(); + PricebookEntry pricebookEntry = new PricebookEntry( + Pricebook2Id = standardPricebookId, + Product2Id = product.Id, + UnitPrice = 10.00, + IsActive = true); + insert pricebookEntry; + + Opportunity opportunity = new Opportunity( + Name = 'Quote Opportunity', + AccountId = accountId, + StageName = 'Prospecting', + CloseDate = Date.today().addDays(30)); + insert opportunity; + + Quote quote = new Quote( + Name = 'Quote', + OpportunityId = opportunity.Id, + Pricebook2Id = standardPricebookId); + insert quote; + + QuoteLineItem quoteLineItem = new QuoteLineItem( + QuoteId = quote.Id, + PricebookEntryId = pricebookEntry.Id, + Quantity = 1, + UnitPrice = 10.00); + insert quoteLineItem; + + return quoteLineItem.Id; + } + + /** + * @description Create a WebCart with the specific status. + * @param cartStatus Status of the Cart + * + * @return ID of the WebCart + */ + private static ID createCartWithSpecifiedStatus(CartExtension.CartStatusEnum cartStatus) { + Account account = new Account(Name = ACCOUNT_NAME); + insert account; + + WebStore webStore = new WebStore(Name = WEBSTORE_NAME); + insert webStore; + + WebCart webCart = new WebCart( + Name = CART_NAME, + WebStoreId = webStore.Id, + AccountId = account.Id, + Status = cartStatus.name()); + insert webCart; + + return webCart.Id; + } + + /** + * @description Add an item to the specified Cart. + * @param cartId ID of the WebCart for which we need to add three items + * + * @return Cart + */ + private static CartExtension.Cart addItemsToCart(ID cartId, Decimal qualifierCount, Decimal targetCount) { + CartDeliveryGroup deliveryGroup = new CartDeliveryGroup(Name = DELIVERYGROUP_NAME, CartId = cartId); + insert deliveryGroup; + + CartItem cartItem1 = new CartItem( + Name = CART_ITEM1_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = qualifierCount, + Product2Id = QUALIFIER_PRODUCT_ID, + SalesPrice = QUALIFIER_SALES_PRICE, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem1; + + CartItem cartItem2 = new CartItem( + Name = CART_ITEM2_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = targetCount, + Product2Id = TARGET_PRODUCT_ID, + SalesPrice = TARGET_SALES_PRICE, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem2; + + // Return Cart + return CartExtension.CartTestUtil.getCart(cartId); + } +} \ No newline at end of file diff --git a/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorAdvanceSample.cls b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorAdvanceSample.cls new file mode 100644 index 0000000..9d1013e --- /dev/null +++ b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorAdvanceSample.cls @@ -0,0 +1,346 @@ +// This sample is for the situations where Shipping Calculation needs to be extended or overridden +// via the extension point for the Shipping Calculator. The Custom Apex Class must be linked to the +// Shipping Calculator extension point and then the integration must be linked to the webstore via +// appropriate Setup +// This sample calculator provides basic functionality and additionally retains the shopper's preferred delivery method based on the conditions outlined below: +// - On the initial checkout, the cheapest available shipping option is selected by default. +// - If the buyer chooses a different delivery method and subsequent checkout changes trigger a shipping recalculation: +// - The previously selected method will be retained if it remains valid. +// - If the previously selected method is no longer valid, the cheapest available option will be selected. +// This class must extend the CartExtension.ShippingCartCalculator class to be processed. +public class ShippingCartCalculatorAdvanceSample extends CartExtension.ShippingCartCalculator { + // You MUST change this to be your service or you must launch your own Third Party Service + // and add the host in Setup | Security | Remote site settings. + private static String externalShippingServiceHost = 'https://example.com'; + + // You MUST change this to be your service or your URL + private static String externalShippingURL = externalShippingServiceHost + '/calculate-shipping-rates'; + + // You MUST change the useExternalService to True if you want to use the Third Party Service. + private static Boolean useExternalService = false; + + public virtual override void calculate(CartExtension.CartCalculateCalculatorRequest request) { + CartExtension.Cart cart = request.getCart(); + // Clean up CVO based on Shipping + CartExtension.CartValidationOutputList cartValidationOutputList = cart.getCartValidationOutputs(); + + for (Integer i = (cartValidationOutputList.size() - 1); i >= 0; i--) { + CartExtension.CartValidationOutput cvo = cartValidationOutputList.get(i); + if (cvo.getType() == CartExtension.CartValidationOutputTypeEnum.SHIPPING) { + cartValidationOutputList.remove(cvo); + } + } + + // To create the Cart delivery group methods, we need to get the ID of the cart delivery group. + CartExtension.CartDeliveryGroupList cartDeliveryGroups = cart.getCartDeliveryGroups(); + if (cartDeliveryGroups.size() == 0) { + CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput( + CartExtension.CartValidationOutputTypeEnum.SHIPPING, + CartExtension.CartValidationOutputLevelEnum.ERROR + ); + cvo.setMessage('No Cart Delivery Groups have been defined'); + cartValidationOutputList.add(cvo); + } else { + CartExtension.CartItemList cartItems = cart.getCartItems(); + Integer numberOfUniqueItems = cartItems.size(); + + for (Integer i = (cartDeliveryGroups.size() - 1); i >= 0; i--) { + CartExtension.CartDeliveryGroup cartDeliveryGroup = cartDeliveryGroups.get(i); + CartExtension.CartDeliveryGroupMethodList cartDeliveryGroupMethods = cartDeliveryGroup.getCartDeliveryGroupMethods(); + CartExtension.CartDeliveryGroupMethod selectedDeliveryMethod = cartDeliveryGroup.getSelectedCartDeliveryGroupMethod(); + + // Clean up the CartDeliveryGroupMethods except already selected Delivery method + for (Integer j = (cartDeliveryGroupMethods.size() - 1); j >= 0; j--) { + CartExtension.CartDeliveryGroupMethod method = cartDeliveryGroupMethods.get(j); + // remove cart delivery group methods if selectedDeliveryMethod is null or delivery method id not matching with selectedDeliveryMethodId + if(selectedDeliveryMethod==null || (selectedDeliveryMethod!=null && !method.getId().equals(selectedDeliveryMethod.getId()))) { + cartDeliveryGroupMethods.remove(method); + } + } + + // Get the Shipping Product + String shippingChargeProduct2Name = 'Shipping Charge Product'; + List shippingProducts = [SELECT Id FROM Product2 WHERE ProductClass != 'VariationParent' and Name = :shippingChargeProduct2Name LIMIT 1]; + if(shippingProducts.size() == 0) { + CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput(CartExtension.CartValidationOutputTypeEnum.SHIPPING, + CartExtension.CartValidationOutputLevelEnum.ERROR ); + cvo.setMessage('No Shipping Products have been defined'); + cartValidationOutputList.add(cvo); + } else { + String shippingProduct = Id.valueOf(shippingProducts[0].Id); + // Create a CartDeliveryGroupMethod record for every shipping option returned from the external service + if(useExternalService) { + // Get shipping options, including aspects like rates and carriers, from the external service. + ShippingOptionsAndRatesFromExternalService[] shippingOptionsAndRatesFromExternalService = getShippingOptionsAndRatesFromExternalService( + numberOfUniqueItems, cartValidationOutputList + ); + + // Create a CartDeliveryGroupMethod record for every shipping option returned from the external + // service and every Order Delivery Method that matches + if(shippingOptionsAndRatesFromExternalService != null){ + populateCartDeliveryGroupMethodWithShippingOptions( + shippingOptionsAndRatesFromExternalService, + cartDeliveryGroupMethods,shippingProduct, + cartValidationOutputList, selectedDeliveryMethod + ); + } + } else { + // this block is for static response for sample class + if(selectedDeliveryMethod==null || + (selectedDeliveryMethod!=null && + !('Ground Shipping'.equals(selectedDeliveryMethod.getName()) && + selectedDeliveryMethod.getShippingFee().equals(10.99) && + selectedDeliveryMethod.getCarrier().equals('USPS') && + selectedDeliveryMethod.getClassOfService().equals('Ground Shipping') && + selectedDeliveryMethod.getTransitTimeMin().equals(1) && + selectedDeliveryMethod.getTransitTimeMax().equals(3) && + selectedDeliveryMethod.getTransitTimeUnit().equals(CartExtension.TimeUnitEnum.DAYS) && + selectedDeliveryMethod.getProcessTime().equals(1) && + selectedDeliveryMethod.getProcessTimeUnit().equals(CartExtension.TimeUnitEnum.WEEKS)))) { + CartExtension.CartDeliveryGroupMethod cartDeliveryGroupMethod01 = new CartExtension.CartDeliveryGroupMethod('Ground Shipping', 10.99, shippingProduct); + cartDeliveryGroupMethod01.setCarrier('USPS'); + cartDeliveryGroupMethod01.setClassOfService('Ground Shipping'); + cartDeliveryGroupMethod01.setTransitTimeMin(1); + cartDeliveryGroupMethod01.setTransitTimeMax(3); + cartDeliveryGroupMethod01.setTransitTimeUnit(CartExtension.TimeUnitEnum.DAYS); + cartDeliveryGroupMethod01.setProcessTime(1); + cartDeliveryGroupMethod01.setProcessTimeUnit(CartExtension.TimeUnitEnum.WEEKS); + cartDeliveryGroupMethods.add(cartDeliveryGroupMethod01); + } + + if(selectedDeliveryMethod==null || + (selectedDeliveryMethod!=null && + !('Next Day Air'.equals(selectedDeliveryMethod.getName()) && + selectedDeliveryMethod.getShippingFee().equals(15.99) && + selectedDeliveryMethod.getCarrier().equals('UPS') && + selectedDeliveryMethod.getClassOfService().equals('Next Day Air') && + selectedDeliveryMethod.getTransitTimeMin().equals(1) && + selectedDeliveryMethod.getTransitTimeMax().equals(4) && + selectedDeliveryMethod.getTransitTimeUnit().equals(CartExtension.TimeUnitEnum.DAYS) && + selectedDeliveryMethod.getProcessTime().equals(1) && + selectedDeliveryMethod.getProcessTimeUnit().equals(CartExtension.TimeUnitEnum.DAYS)))) { + CartExtension.CartDeliveryGroupMethod cartDeliveryGroupMethod02 = new CartExtension.CartDeliveryGroupMethod('Next Day Air', 15.99, shippingProduct); + cartDeliveryGroupMethod02.setCarrier('UPS'); + cartDeliveryGroupMethod02.setClassOfService('Next Day Air'); + cartDeliveryGroupMethod02.setTransitTimeMin(1); + cartDeliveryGroupMethod02.setTransitTimeMax(4); + cartDeliveryGroupMethod02.setTransitTimeUnit(CartExtension.TimeUnitEnum.DAYS); + cartDeliveryGroupMethod02.setProcessTime(1); + cartDeliveryGroupMethod02.setProcessTimeUnit(CartExtension.TimeUnitEnum.DAYS); + cartDeliveryGroupMethods.add(cartDeliveryGroupMethod02); + } + } + } + } + } + } + + private static String generateRandomString(Integer len) { + final String chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz'; + String randStr = ''; + while (randStr.length() < len) { + Integer idx = Math.mod(Math.abs(Crypto.getRandomInteger()), chars.length()); + randStr += chars.substring(idx, idx+1); + } + return randStr; + } + + // Note: This sample method currently only takes in numberOfUniqueItems as an input parameter. For + // real-world scenarios, expand the parameter list. + private ShippingOptionsAndRatesFromExternalService[] getShippingOptionsAndRatesFromExternalService( + Integer numberOfUniqueItems, CartExtension.CartValidationOutputList cartValidationOutputCollection) { + final Integer SuccessfulHttpRequest = 200; + ShippingOptionsAndRatesFromExternalService[] shippingOptions = new List(); + Http http = new Http(); + HttpRequest request = new HttpRequest(); + request.setEndpoint(externalShippingURL); + request.setMethod('GET'); + HttpResponse response = http.send(request); + + // If the request is successful, parse the JSON response. The response looks like this: + // [{"status":"calculated","rate":{"name":"Delivery Method 1","serviceName":"Test Carrier 1","serviceCode":"SNC9600","shipmentCost":11.99,"otherCost":5.99}}, undefined undefined + // {"status":"calculated","rate":{"name":"Delivery Method 2","serviceName":"Test Carrier + // 2","serviceCode":"SNC9600","shipmentCost":15.99,"otherCost":6.99}}] + if (response.getStatusCode() == SuccessfulHttpRequest) { + List results = (List) JSON.deserializeUntyped(response.getBody()); + for (Object result : results) { + Map subresult = (Map) result; + Map providerAndRate = (Map) subresult.get('rate'); + shippingOptions.add( new ShippingOptionsAndRatesFromExternalService( + (String) providerAndRate.get('name'), + (String) providerAndRate.get('serviceCode'), + (Decimal) providerAndRate.get('shipmentCost'), + (Decimal) providerAndRate.get('otherCost'), + (String) providerAndRate.get('serviceName'), + (String) providerAndRate.get('serviceName'), + (String) providerAndRate.get('serviceCode'), + generateRandomString(10), + true, + (Integer) providerAndRate.get('transitTimeMin'), + (Integer) providerAndRate.get('transitTimeMax'), + (CartExtension.TimeUnitEnum) providerAndRate.get('transitTimeUnit'), + (Integer) providerAndRate.get('processTime'), + (CartExtension.TimeUnitEnum) providerAndRate.get('processTimeUnit') + )); + } + return shippingOptions; + } else { + String errorMessage = 'We failed to calculate shipping options for your cart.'; + if(response.getStatusCode() == 404) { + errorMessage = '404. You must create a sample application or add your own service which returns a valid response'; + } + + // Create a CVO with the Error + CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput( + CartExtension.CartValidationOutputTypeEnum.SHIPPING, + CartExtension.CartValidationOutputLevelEnum.ERROR + ); + cvo.setMessage(errorMessage); + cartValidationOutputCollection.add(cvo); + return null; + } + } + + // Structure to store the shipping options retrieved from external service. + Class ShippingOptionsAndRatesFromExternalService { + private String name; + private String provider; + private Decimal rate; + private Decimal otherCost; + private String serviceName; + private String carrier; + private String classOfService; + private String referenceNumber; + private Boolean isActive; + private Integer transitTimeMin; + private Integer transitTimeMax; + private CartExtension.TimeUnitEnum transitTimeUnit; + private Integer processTime; + private CartExtension.TimeUnitEnum processTimeUnit; + + public ShippingOptionsAndRatesFromExternalService() { + name = ''; + provider = ''; + rate = 0.0; + serviceName = ''; + otherCost = 0.0; + carrier = ''; + classOfService = ''; + referenceNumber = ''; + isActive = true; + transitTimeMin = 0; + transitTimeMax = 0; + transitTimeUnit = null; + processTime = 0; + processTimeUnit = null; + } + + public ShippingOptionsAndRatesFromExternalService(String someName, String someProvider, Decimal someRate, Decimal someOtherCost, String someServiceName, + String someCarrier, String someClassOfService, String someReferenceNumber, Boolean someIsActive, + Integer someTransitTimeMin, Integer someTransitTimeMax,CartExtension.TimeUnitEnum someTransitTimeUnit, Integer someProcessTime, + CartExtension.TimeUnitEnum someProcessTimeUnit) { + name = someName; + provider = someProvider; + rate = someRate; + otherCost = someOtherCost; + serviceName = someServiceName; + carrier = someCarrier; + classOfService = someClassOfService; + referenceNumber = someReferenceNumber; + isActive = someIsActive; + transitTimeMin = someTransitTimeMin; + transitTimeMax = someTransitTimeMax; + transitTimeUnit = someTransitTimeUnit; + processTime = someProcessTime; + processTimeUnit = someProcessTimeUnit; + } + + public String getProvider() { return provider; } + public Decimal getRate() { return rate; } + public Decimal getOtherCost() { return otherCost; } + public String getServiceName() { return serviceName; } + public String getName() { return name; } + public String getCarrier() { return carrier; } + public String getClassOfService() { return classOfService; } + public String getReferenceNumber() { return referenceNumber; } + public Boolean isActive() { return isActive; } + public Integer getTransitTimeMin() { return transitTimeMin; } + public Integer getTransitTimeMax() { return transitTimeMax; } + public CartExtension.TimeUnitEnum getTransitTimeUnit() { return transitTimeUnit; } + public Integer getProcessTime() { return processTime; } + public CartExtension.TimeUnitEnum getProcessTimeUnit() { return processTimeUnit; } + } + + + private void populateCartDeliveryGroupMethodWithShippingOptions( + List shippingOptions, + CartExtension.CartDeliveryGroupMethodList cartDeliveryGroupMethodCollection, + String shippingProduct, + CartExtension.CartValidationOutputList cartValidationOutputCollection, + CartExtension.CartDeliveryGroupMethod selectedDeliveryMethod + ) { + for (ShippingOptionsAndRatesFromExternalService shippingOption : shippingOptions) { + //if selected CDGM is matching with shipping option then we don't need to create shipping option and existing can be reused + if(!isShippingOptionMatchingWithSelectedDM(shippingOption,selectedDeliveryMethod)){ + String carrier = shippingOption.serviceName; + String classOfService = shippingOption.provider; + // Create a CartDeliveryGroupMethod for every shipping option returned from the external + // service + CartExtension.CartDeliveryGroupMethod cartDeliveryGroupMethod = new CartExtension.CartDeliveryGroupMethod( + shippingOption.getName(), + shippingOption.getRate(), + shippingProduct + ); + cartDeliveryGroupMethod.setExternalProvider(shippingOption.getProvider()); + cartDeliveryGroupMethod.setCarrier(shippingOption.getCarrier()); + cartDeliveryGroupMethod.setClassOfService(shippingOption.getClassOfService()); + cartDeliveryGroupMethod.setIsActive(shippingOption.isActive()); + cartDeliveryGroupMethod.setReferenceNumber(shippingOption.getReferenceNumber()); + cartDeliveryGroupMethodCollection.add(cartDeliveryGroupMethod); + cartDeliveryGroupMethod.setTransitTimeMin(shippingOption.getTransitTimeMin()); + cartDeliveryGroupMethod.setTransitTimeMax(shippingOption.getTransitTimeMax()); + cartDeliveryGroupMethod.setTransitTimeUnit(shippingOption.getTransitTimeUnit()); + cartDeliveryGroupMethod.setProcessTime(shippingOption.getProcessTime()); + cartDeliveryGroupMethod.setProcessTimeUnit(shippingOption.getProcessTimeUnit()); + } + } + } + + /** + This method compares previous Selected Delivery method with current shipping options and if both matches returns ture + */ + private boolean isShippingOptionMatchingWithSelectedDM(ShippingOptionsAndRatesFromExternalService shippingOption, CartExtension.CartDeliveryGroupMethod previousSelectDeliveryMethod) { + if(previousSelectDeliveryMethod != null) { + // get delivery group method for seletctedDMId + //CartDeliveryGroupMethod previousSelectDeliveryMethod = [SELECT Name, ShippingFee, WebCartId, Carrier, ClassOfService, ExternalProvider, ProductId, ReferenceNumber, IsActive, TransitTimeMin, TransitTimeMax, TransitTimeUnit, ProcessTime, ProcessTimeUnit FROM CartDeliveryGroupMethod WHERE Id= :previousSelectDeliveryMethodId]; + + // return if all fields of shipping option matches with selectedDM else return false + return (previousSelectDeliveryMethod.getName().equals(shippingOption.getName()) && // compare name + previousSelectDeliveryMethod.getIsActive().equals(shippingOption.isActive()) && // compare isActive flag + + previousSelectDeliveryMethod.getShippingFee().equals(shippingOption.getRate()) && // compare shipping fee + + isNullOrEquals(previousSelectDeliveryMethod.getProcessTime(), shippingOption.getProcessTime()) && // compare time + isNullOrEquals(previousSelectDeliveryMethod.getProcessTimeUnit(), shippingOption.getProcessTimeUnit()) && // compare time unit + + // ideally reference number should match but in this sample we are generating random string so won't match + //previousSelectDeliveryMethod.getReferenceNumber().equals(shippingOption.getReferenceNumber()) && + previousSelectDeliveryMethod.getCarrier().equals(shippingOption.getCarrier()) && + previousSelectDeliveryMethod.getClassOfService().equals(shippingOption.getClassOfService()) && + previousSelectDeliveryMethod.getExternalProvider().equals(shippingOption.getProvider()) && + + isNullOrEquals(previousSelectDeliveryMethod.getTransitTimeMax(), shippingOption.getTransitTimeMax()) && // compare transit time + isNullOrEquals(previousSelectDeliveryMethod.getTransitTimeMin(), shippingOption.getTransitTimeMin()) && + isNullOrEquals(previousSelectDeliveryMethod.getTransitTimeUnit(), shippingOption.getTransitTimeUnit())); // compare transit time unit + } + // we will return false so DM can be created if no previous selected DM is null + return false; + } + + /** + This method compares two objects, if both are null or equals returns true + */ + private boolean isNullOrEquals(Object o1, Object o2) { + return (o1 == null && o2 == null) || (o1 != null && o1.equals(o2)); + } +} diff --git a/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorAdvanceSample.cls-meta.xml b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorAdvanceSample.cls-meta.xml new file mode 100644 index 0000000..1e7de94 --- /dev/null +++ b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorAdvanceSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + Active + diff --git a/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorAdvanceSampleTest.cls b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorAdvanceSampleTest.cls new file mode 100644 index 0000000..22b0347 --- /dev/null +++ b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorAdvanceSampleTest.cls @@ -0,0 +1,164 @@ +/** + * An Apex Class which tests the ShippingCartCalculatorAdvanceSample + */ +@IsTest +global with sharing class ShippingCartCalculatorAdvanceSampleTest { + + @IsTest + static void testCartWithNoCartDeliveryGroup() { + // Arrange + CartExtension.Cart cart = CartExtension.CartTestUtil.createCart(); + CartExtension.CartDeliveryGroupList deliveryGroups = cart.getCartDeliveryGroups(); + CartExtension.CartDeliveryGroup deliveryGroup = deliveryGroups.get(0); + deliveryGroups.remove(deliveryGroup); + + // Act + Test.startTest(); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + ShippingCartCalculatorAdvanceSample calculator = new ShippingCartCalculatorAdvanceSample(); + calculator.calculate(request); + Test.stopTest(); + + // Assert + CartExtension.CartValidationOutputList cartValidationOutputs = cart.getCartValidationOutputs(); + System.assertEquals(1, cartValidationOutputs.size()); + CartExtension.CartValidationOutput cvo = cartValidationOutputs.get(0); + System.assertEquals(CartExtension.CartValidationOutputTypeEnum.SHIPPING, cvo.getType()); + System.assertEquals('No Cart Delivery Groups have been defined', cvo.getMessage()); + } + + @IsTest + static void testShippingMethodsAreCreated() { + // Arrange + CartExtension.Cart cart = CartExtension.CartTestUtil.createCart(); + getDefaultShippingChargeProduct2Id(); + + // Act + Test.startTest(); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + ShippingCartCalculatorAdvanceSample calculator = new ShippingCartCalculatorAdvanceSample(); + calculator.calculate(request); + Test.stopTest(); + + // Assert + // Test if no CVO is created + CartExtension.CartValidationOutputList cartValidationOutputs = cart.getCartValidationOutputs(); + System.assertEquals(0, cartValidationOutputs.size()); + + // Test if CartDeliveryGroupMethod is created + CartExtension.CartDeliveryGroupList deliveryGroups = cart.getCartDeliveryGroups(); + CartExtension.CartDeliveryGroup deliveryGroup = deliveryGroups.get(0); + + CartExtension.CartDeliveryGroupMethodList deliveryMethods = deliveryGroup.getCartDeliveryGroupMethods(); + System.assertEquals(2, deliveryMethods.size()); + CartExtension.CartDeliveryGroupMethod deliveryMethod01 = deliveryMethods.get(0); + System.assertEquals(10.99, deliveryMethod01.getShippingFee()); + System.assertEquals('Ground Shipping', deliveryMethod01.getName()); + System.assertEquals('USPS', deliveryMethod01.getCarrier()); + System.assertEquals('Ground Shipping', deliveryMethod01.getClassOfService()); + System.assertEquals(1, deliveryMethod01.getTransitTimeMin()); + System.assertEquals(3, deliveryMethod01.getTransitTimeMax()); + System.assertEquals('DAYS', deliveryMethod01.getTransitTimeUnit().toString()); + System.assertEquals(1, deliveryMethod01.getProcessTime()); + System.assertEquals('WEEKS', deliveryMethod01.getProcessTimeUnit().toString()); + + CartExtension.CartDeliveryGroupMethod deliveryMethod02 = deliveryMethods.get(1); + System.assertEquals(15.99, deliveryMethod02.getShippingFee()); + System.assertEquals('Next Day Air', deliveryMethod02.getName()); + System.assertEquals('UPS', deliveryMethod02.getCarrier()); + System.assertEquals('Next Day Air', deliveryMethod02.getClassOfService()); + System.assertEquals(1, deliveryMethod02.getTransitTimeMin()); + System.assertEquals(4, deliveryMethod02.getTransitTimeMax()); + System.assertEquals('DAYS', deliveryMethod02.getTransitTimeUnit().toString()); + System.assertEquals(1, deliveryMethod02.getProcessTime()); + System.assertEquals('DAYS', deliveryMethod02.getProcessTimeUnit().toString()); + } + private static Id getDefaultShippingChargeProduct2Id() { + + // Check to see if a Product2 with name 'Shipping Charge' already exists. + // If it doesn't exist, create one. + String shippingChargeProduct2Name = 'Shipping Charge Product'; + List shippingChargeProducts = [SELECT Id FROM Product2 WHERE Name = :shippingChargeProduct2Name]; + if (shippingChargeProducts.isEmpty()) { + Product2 shippingChargeProduct = new Product2( + isActive = true, + Name = shippingChargeProduct2Name + ); + insert(shippingChargeProduct); + return shippingChargeProduct.Id; + } else { + return shippingChargeProducts[0].Id; + } + } + + @IsTest + static void testShippingMethodsAreCreated_retainSelectedDeliveryMethod() { + // Arrange + CartExtension.Cart cart = CartExtension.CartTestUtil.createCart(); + getDefaultShippingChargeProduct2Id(); + + // Act + Test.startTest(); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + ShippingCartCalculatorAdvanceSample calculator = new ShippingCartCalculatorAdvanceSample(); + //calculator.calculate(request); + + // Assert + // Test if no CVO is created + CartExtension.CartValidationOutputList cartValidationOutputs = cart.getCartValidationOutputs(); + System.assertEquals(0, cartValidationOutputs.size()); + + //find prevSelectDeliveryMethod for first cart DG + CartExtension.CartDeliveryGroupList deliveryGroups = cart.getCartDeliveryGroups(); + CartExtension.CartDeliveryGroup deliveryGroup1 = deliveryGroups.get(0); + CartExtension.CartDeliveryGroupMethod prevSelectDeliveryMethod = deliveryGroup1.getSelectedCartDeliveryGroupMethod(); + + //find delivery methods + CartExtension.CartDeliveryGroupMethodList deliveryMethods = deliveryGroup1.getCartDeliveryGroupMethods(); + + //identify the delivery method id which is not matching with the selected delivery method id + CartExtension.CartDeliveryGroupMethod nonMatchingCDGM = null; + for (Integer j = (deliveryMethods.size() - 1); j >= 0; j--) { + CartExtension.CartDeliveryGroupMethod cdgm = deliveryMethods.get(j); + if(prevSelectDeliveryMethod==null || cdgm.getId() != prevSelectDeliveryMethod.getId()) { + nonMatchingCDGM = cdgm; + break; + } + } + //udpate cart delivery group with new selected deliver method id which is not matching + deliveryGroup1.setSelectedCartDeliveryGroupMethod(nonMatchingCDGM); + + // call calculator + calculator.calculate(request); + + + // Test if CartDeliveryGroupMethod is created + deliveryGroups = cart.getCartDeliveryGroups(); + + CartExtension.CartDeliveryGroup deliveryGroup = deliveryGroups.get(0); + System.assertEquals(2, deliveryMethods.size()); + CartExtension.CartDeliveryGroupMethod deliveryMethod01 = deliveryMethods.get(0); + System.assertEquals(10.99, deliveryMethod01.getShippingFee()); + System.assertEquals('Ground Shipping', deliveryMethod01.getName()); + System.assertEquals('USPS', deliveryMethod01.getCarrier()); + System.assertEquals('Ground Shipping', deliveryMethod01.getClassOfService()); + System.assertEquals(1, deliveryMethod01.getTransitTimeMin()); + System.assertEquals(3, deliveryMethod01.getTransitTimeMax()); + System.assertEquals('DAYS', deliveryMethod01.getTransitTimeUnit().toString()); + System.assertEquals(1, deliveryMethod01.getProcessTime()); + System.assertEquals('WEEKS', deliveryMethod01.getProcessTimeUnit().toString()); + + CartExtension.CartDeliveryGroupMethod deliveryMethod02 = deliveryMethods.get(1); + System.assertEquals(15.99, deliveryMethod02.getShippingFee()); + System.assertEquals('Next Day Air', deliveryMethod02.getName()); + System.assertEquals('UPS', deliveryMethod02.getCarrier()); + System.assertEquals('Next Day Air', deliveryMethod02.getClassOfService()); + System.assertEquals(1, deliveryMethod02.getTransitTimeMin()); + System.assertEquals(4, deliveryMethod02.getTransitTimeMax()); + System.assertEquals('DAYS', deliveryMethod02.getTransitTimeUnit().toString()); + System.assertEquals(1, deliveryMethod02.getProcessTime()); + System.assertEquals('DAYS', deliveryMethod02.getProcessTimeUnit().toString()); + Test.stopTest(); + } + +} diff --git a/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorSample.cls b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorSample.cls index 8f70293..d54da55 100644 --- a/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorSample.cls +++ b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorSample.cls @@ -39,7 +39,7 @@ public class ShippingCartCalculatorSample extends CartExtension.ShippingCartCalc } else { CartExtension.CartItemList cartItems = cart.getCartItems(); Integer numberOfUniqueItems = cartItems.size(); - + for (Integer i = (cartDeliveryGroups.size() - 1); i >= 0; i--) { CartExtension.CartDeliveryGroup cartDeliveryGroup = cartDeliveryGroups.get(i); CartExtension.CartDeliveryGroupMethodList cartDeliveryGroupMethods = cartDeliveryGroup.getCartDeliveryGroupMethods(); @@ -51,9 +51,10 @@ public class ShippingCartCalculatorSample extends CartExtension.ShippingCartCalc } // To clear selected Cart Delivery Group Method cartDeliveryGroup.setSelectedCartDeliveryGroupMethod(null); - + // Get the Shipping Product - List shippingProducts = [SELECT Id FROM Product2 WHERE ProductClass != 'VariationParent' LIMIT 1]; + String shippingChargeProduct2Name = 'Shipping Charge Product'; + List shippingProducts = [SELECT Id FROM Product2 WHERE ProductClass != 'VariationParent' and Name = :shippingChargeProduct2Name LIMIT 1]; if(shippingProducts.size() == 0) { CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput(CartExtension.CartValidationOutputTypeEnum.SHIPPING, CartExtension.CartValidationOutputLevelEnum.ERROR ); @@ -81,13 +82,23 @@ public class ShippingCartCalculatorSample extends CartExtension.ShippingCartCalc CartExtension.CartDeliveryGroupMethod cartDeliveryGroupMethod01 = new CartExtension.CartDeliveryGroupMethod('Ground Shipping', 10.99, shippingProduct); cartDeliveryGroupMethod01.setCarrier('USPS'); cartDeliveryGroupMethod01.setClassOfService('Ground Shipping'); + cartDeliveryGroupMethod01.setTransitTimeMin(1); + cartDeliveryGroupMethod01.setTransitTimeMax(3); + cartDeliveryGroupMethod01.setTransitTimeUnit(CartExtension.TimeUnitEnum.DAYS); + cartDeliveryGroupMethod01.setProcessTime(1); + cartDeliveryGroupMethod01.setProcessTimeUnit(CartExtension.TimeUnitEnum.WEEKS); CartExtension.CartDeliveryGroupMethod cartDeliveryGroupMethod02 = new CartExtension.CartDeliveryGroupMethod('Next Day Air', 15.99, shippingProduct); cartDeliveryGroupMethod02.setCarrier('UPS'); cartDeliveryGroupMethod02.setClassOfService('Next Day Air'); + cartDeliveryGroupMethod02.setTransitTimeMin(1); + cartDeliveryGroupMethod02.setTransitTimeMax(4); + cartDeliveryGroupMethod02.setTransitTimeUnit(CartExtension.TimeUnitEnum.DAYS); + cartDeliveryGroupMethod02.setProcessTime(1); + cartDeliveryGroupMethod02.setProcessTimeUnit(CartExtension.TimeUnitEnum.DAYS); cartDeliveryGroupMethods.add(cartDeliveryGroupMethod01); cartDeliveryGroupMethods.add(cartDeliveryGroupMethod02); } - } + } } } } @@ -132,7 +143,12 @@ public class ShippingCartCalculatorSample extends CartExtension.ShippingCartCalc (String) providerAndRate.get('serviceName'), (String) providerAndRate.get('serviceCode'), generateRandomString(10), - true + true, + (Integer) providerAndRate.get('transitTimeMin'), + (Integer) providerAndRate.get('transitTimeMax'), + (CartExtension.TimeUnitEnum) providerAndRate.get('transitTimeUnit'), + (Integer) providerAndRate.get('processTime'), + (CartExtension.TimeUnitEnum) providerAndRate.get('processTimeUnit') )); } return shippingOptions; @@ -164,6 +180,11 @@ public class ShippingCartCalculatorSample extends CartExtension.ShippingCartCalc private String classOfService; private String referenceNumber; private Boolean isActive; + private Integer transitTimeMin; + private Integer transitTimeMax; + private CartExtension.TimeUnitEnum transitTimeUnit; + private Integer processTime; + private CartExtension.TimeUnitEnum processTimeUnit; public ShippingOptionsAndRatesFromExternalService() { name = ''; @@ -175,10 +196,17 @@ public class ShippingCartCalculatorSample extends CartExtension.ShippingCartCalc classOfService = ''; referenceNumber = ''; isActive = true; + transitTimeMin = 0; + transitTimeMax = 0; + transitTimeUnit = null; + processTime = 0; + processTimeUnit = null; } public ShippingOptionsAndRatesFromExternalService(String someName, String someProvider, Decimal someRate, Decimal someOtherCost, String someServiceName, - String someCarrier, String someClassOfService, String someReferenceNumber, Boolean someIsActive) { + String someCarrier, String someClassOfService, String someReferenceNumber, Boolean someIsActive, + Integer someTransitTimeMin, Integer someTransitTimeMax,CartExtension.TimeUnitEnum someTransitTimeUnit, Integer someProcessTime, + CartExtension.TimeUnitEnum someProcessTimeUnit) { name = someName; provider = someProvider; rate = someRate; @@ -188,6 +216,11 @@ public class ShippingCartCalculatorSample extends CartExtension.ShippingCartCalc classOfService = someClassOfService; referenceNumber = someReferenceNumber; isActive = someIsActive; + transitTimeMin = someTransitTimeMin; + transitTimeMax = someTransitTimeMax; + transitTimeUnit = someTransitTimeUnit; + processTime = someProcessTime; + processTimeUnit = someProcessTimeUnit; } public String getProvider() { return provider; } @@ -199,6 +232,11 @@ public class ShippingCartCalculatorSample extends CartExtension.ShippingCartCalc public String getClassOfService() { return classOfService; } public String getReferenceNumber() { return referenceNumber; } public Boolean isActive() { return isActive; } + public Integer getTransitTimeMin() { return transitTimeMin; } + public Integer getTransitTimeMax() { return transitTimeMax; } + public CartExtension.TimeUnitEnum getTransitTimeUnit() { return transitTimeUnit; } + public Integer getProcessTime() { return processTime; } + public CartExtension.TimeUnitEnum getProcessTimeUnit() { return processTimeUnit; } } @@ -224,6 +262,11 @@ public class ShippingCartCalculatorSample extends CartExtension.ShippingCartCalc cartDeliveryGroupMethod.setIsActive(shippingOption.isActive()); cartDeliveryGroupMethod.setReferenceNumber(shippingOption.getReferenceNumber()); cartDeliveryGroupMethodCollection.add(cartDeliveryGroupMethod); + cartDeliveryGroupMethod.setTransitTimeMin(shippingOption.getTransitTimeMin()); + cartDeliveryGroupMethod.setTransitTimeMax(shippingOption.getTransitTimeMax()); + cartDeliveryGroupMethod.setTransitTimeUnit(shippingOption.getTransitTimeUnit()); + cartDeliveryGroupMethod.setProcessTime(shippingOption.getProcessTime()); + cartDeliveryGroupMethod.setProcessTimeUnit(shippingOption.getProcessTimeUnit()); } } } diff --git a/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorSampleTest.cls b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorSampleTest.cls index ca87998..d343ae5 100644 --- a/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorSampleTest.cls +++ b/commerce/domain/shipping/cart/calculator/classes/ShippingCartCalculatorSampleTest.cls @@ -31,6 +31,7 @@ global with sharing class ShippingCartCalculatorSampleTest { static void testShippingMethodsAreCreated() { // Arrange CartExtension.Cart cart = CartExtension.CartTestUtil.createCart(); + getDefaultShippingChargeProduct2Id(); // Act Test.startTest(); @@ -55,10 +56,37 @@ global with sharing class ShippingCartCalculatorSampleTest { System.assertEquals('Ground Shipping', deliveryMethod01.getName()); System.assertEquals('USPS', deliveryMethod01.getCarrier()); System.assertEquals('Ground Shipping', deliveryMethod01.getClassOfService()); + System.assertEquals(1, deliveryMethod01.getTransitTimeMin()); + System.assertEquals(3, deliveryMethod01.getTransitTimeMax()); + System.assertEquals('D', deliveryMethod01.getTransitTimeUnit()); + System.assertEquals(1, deliveryMethod01.getProcessTime()); + System.assertEquals('W', deliveryMethod01.getProcessTimeUnit()); CartExtension.CartDeliveryGroupMethod deliveryMethod02 = deliveryMethods.get(1); System.assertEquals(15.99, deliveryMethod02.getShippingFee()); System.assertEquals('Next Day Air', deliveryMethod02.getName()); System.assertEquals('UPS', deliveryMethod02.getCarrier()); System.assertEquals('Next Day Air', deliveryMethod02.getClassOfService()); + System.assertEquals(1, deliveryMethod02.getTransitTimeMin()); + System.assertEquals(4, deliveryMethod02.getTransitTimeMax()); + System.assertEquals('D', deliveryMethod02.getTransitTimeUnit()); + System.assertEquals(1, deliveryMethod02.getProcessTime()); + System.assertEquals('D', deliveryMethod02.getProcessTimeUnit()); } + private Id getDefaultShippingChargeProduct2Id() { + + // Check to see if a Product2 with name 'Shipping Charge' already exists. + // If it doesn't exist, create one. + String shippingChargeProduct2Name = 'Shipping Charge Product'; + List shippingChargeProducts = [SELECT Id FROM Product2 WHERE Name = :shippingChargeProduct2Name]; + if (shippingChargeProducts.isEmpty()) { + Product2 shippingChargeProduct = new Product2( + isActive = true, + Name = shippingChargeProduct2Name + ); + insert(shippingChargeProduct); + return shippingChargeProduct.Id; + } else { + return shippingChargeProducts[0].Id; + } + } } diff --git a/commerce/domain/tax/cart/calculator/classes/TaxCartCalculatorSample.cls b/commerce/domain/tax/cart/calculator/classes/TaxCartCalculatorSample.cls index bd0a712..ec2160c 100644 --- a/commerce/domain/tax/cart/calculator/classes/TaxCartCalculatorSample.cls +++ b/commerce/domain/tax/cart/calculator/classes/TaxCartCalculatorSample.cls @@ -2,381 +2,102 @@ // information for a cart item and its adjustments and saves it to a cart data transfer object // (DTO). For a tax calculator extension to be processed by the checkout flow, you must implement the // CartExtension.TaxCartCalculator class. -public class TaxCartCalculatorSample extends CartExtension.TaxCartCalculator { +// +// You need to have a good reason to use this extention point. For example, if you need to use cart custom fields in your calculation. +// Always check that commercestoretax.TaxService extention point isn't enough for you before extending the TaxCartCalculator. +// Extending commercestoretax.TaxService is required if you deal with subscription products and the TaxCartCalculator must call the commercestoretax.TaxService +// if overriden. +// +// Disclaimer: the code listed here is a sample that hasn't been tested for production use. Always test your code before releasing to production. +public with sharing class TaxCartCalculatorSample extends CartExtension.TaxCartCalculator { -// You MUST change this to be your service or you must launch your own Third Party Service -// and add the host in Setup | Security | Remote site settings. - private static String externalTaxHost = 'https://example.com'; + // Disclaimer: the code listed here is a sample that hasn't been tested for production use. Always test your code before releasing to production. + public virtual override void calculate(CartExtension.CartCalculateCalculatorRequest request) { + try { + CartExtension.Cart cart = request.getCart(); - // You MUST change the useExternalService to True if you want to use the Third Party Service. - private static Boolean useExternalService = false; - public virtual override void calculate(CartExtension.CartCalculateCalculatorRequest request) { - try { - CartExtension.Cart cart = request.getCart(); + CartExtension.CartDeliveryGroupList cartDeliveryGroups = cart.getCartDeliveryGroups(); + + Integer cartItemIdSeq = 0; - // Clean up CVO based on tax. When new tax calculator request comes, we need to clean up - // previous CVOs as they have been previously handled by the Cart Calculate API. - CartExtension.CartValidationOutputList cartValidationOutputCollection = cart.getCartValidationOutputs(); - for (Integer i = (cartValidationOutputCollection.size() - 1); i >= 0; i--) { - CartExtension.CartValidationOutput cvo = cartValidationOutputCollection.get(i); - if (cvo.getType() == CartExtension.CartValidationOutputTypeEnum.TAXES) { - cartValidationOutputCollection.remove(cvo); - } - } - - // There should be one delivery group per cart. - CartExtension.CartDeliveryGroupList cartDeliveryGroups = cart.getCartDeliveryGroups(); - CartExtension.CartDeliveryGroup cartDeliveryGroup = cartDeliveryGroups.get(0); + // Cart might have multiple delivery groups, you should handle that + CartExtension.CartDeliveryGroup cartDeliveryGroup = cartDeliveryGroups.get(0); - // Map cart ID to cart item with type Product. - CartExtension.CartItemList cartItemCollection = cart.getCartItems(); - - // The cartItemCollection contains both products and shipping cart items. - Map cartItemById = new Map(); - Map shippingItemById = new Map(); - for (Integer i = (cartItemCollection.size() - 1); i >= 0; i--) { - if (cartItemCollection.get(i).getType() == CartExtension.SalesItemTypeEnum.PRODUCT) { - cartItemById.put(cartItemCollection.get(i).getId(), cartItemCollection.get(i)); - } else if (cartItemCollection.get(i).getType() == CartExtension.SalesItemTypeEnum.CHARGE) { - // Shipping cart items are uniquely identified using delivery group id. - CartExtension.CartDeliveryGroup deliveryGroup = cartItemCollection.get(i).getCartDeliveryGroup(); - shippingItemById.put(deliveryGroup.getId(), cartItemCollection.get(i)); - } - } + // Map cart ID to cart item with type Product. + CartExtension.CartItemList cartItemCollection = cart.getCartItems(); - // Get the tax rates and tax amounts from an external service for all given products and its - // adjustments. - Map dataFromExternalService = null; - Map dataFromExternalServiceForShippingItems = null; - if(useExternalService){ - dataFromExternalService = getTaxesFromExternalService( - cartItemById, - CartDeliveryGroup.getDeliverToAddress().getState(), - CartDeliveryGroup.getDeliverToAddress().getCountry(), - cart.getTaxType() - ); - dataFromExternalServiceForShippingItems = getTaxesFromExternalService( - shippingItemById, - CartDeliveryGroup.getDeliverToAddress().getState(), - CartDeliveryGroup.getDeliverToAddress().getCountry(), - cart.getTaxType() - ); - } else{ - dataFromExternalService = getTaxesFromStaticResponse( - cartItemById, - CartDeliveryGroup.getDeliverToAddress().getState(), - CartDeliveryGroup.getDeliverToAddress().getCountry(), - cart.getTaxType() - ); - dataFromExternalServiceForShippingItems = getTaxesFromStaticResponse( - shippingItemById, - CartDeliveryGroup.getDeliverToAddress().getState(), - CartDeliveryGroup.getDeliverToAddress().getCountry(), - cart.getTaxType() - ); - } + // The cartItemCollection contains both products and shipping cart items. + Map cartItemById = new Map(); - // If no tax details are returned for any cart item, add a cart validation output entry. If - // any invalid scenario found then return. - boolean isCvoPresent = false; - for (String cartItemId : cartItemById.keySet()) { - TaxDataFromExternalService taxDetails = dataFromExternalService.get(cartItemId); - if (taxDetails == null) { - // add cvo - CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput( - CartExtension.CartValidationOutputTypeEnum.TAXES, - CartExtension.CartValidationOutputLevelEnum.INFO - ); - cvo.setMessage('No tax rates configured for this location.'); - cartValidationOutputCollection.add(cvo); - isCvoPresent = true; - } - } - if (isCvoPresent == true) - return; + Iterator cartItemCollectionIterator = cartItemCollection.iterator(); - for (String cartItemId : dataFromExternalService.keySet()) { - TaxDataFromExternalService taxDetailsToCartId = dataFromExternalService.get(cartItemId); - CartExtension.CartItem cartItem = cartItemById.get(cartItemId); + while (cartItemCollectionIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemCollectionIterator.next(); - // NOTE: DELETED items get filtered out in the DtoCollection and if there is no tax setup - // against any cart item, then that's considered an invalid scenario and added to CVO. If - // cart tax numbers are changed that indicates the cart item was MODIFIED, then: - // 1. Delete existing and create new cart tax entries in cart item and cart item - // adjustments. - // 2. Update cart item tax information. Currently, we do not support taxes on tier - // adjustment in an extension. - boolean isCartItemModified = false; - if ( - (cartItem.getNetUnitPrice() != null && - cartItem.getNetUnitPrice() != taxDetailsToCartId.getNetUnitPrice()) || - !VerifyAdjustmentUpdate(cartItem, taxDetailsToCartId) - ) { - if (cartItem.getCartTaxes().size() > 0) { - cartItem.getCartTaxes().remove(cartItem.getCartTaxes().get(0)); - } - for (Integer i = (cartItem.getCartItemPriceAdjustments().size() - 1); i >= 0; i--) { - CartExtension.CartTaxList cipaTaxes = cartItem.getCartItemPriceAdjustments() - .get(i) - .getCartTaxes(); - if (cipaTaxes.size() > 0) { - cipaTaxes.remove(cipaTaxes.get(0)); + String cartItemId = (cartItem.getId() == null) ? String.valueOf(++cartItemIdSeq) : cartItem.getId(); + cartItemById.put(cartItemId, cartItem); + } - } - isCartItemModified = true; - } - - // If there are no existing cart tax entries in the cart item that indicates cart item was - // newly CREATED in the cart then: - // 1. Create new cart tax entries - // 2. Update cart item tax information - if ( - cartItem.getCartTaxes() == null || - cartItem.getCartTaxes().isEmpty() || - isCartItemModified == true - ) { - cartItem.setNetUnitPrice(taxDetailsToCartId.getNetUnitPrice()); - cartItem.setGrossUnitPrice(taxDetailsToCartId.getGrossUnitPrice()); - cartItem.setAdjustmentTaxAmount(taxDetailsToCartId.getAdjustmentTaxAmount()); - CartExtension.CartTaxList cartTaxCollection = cartItem.getCartTaxes(); - CartExtension.CartTax cartTax = new CartExtension.CartTax( - CartExtension.TaxTypeEnum.ESTIMATED, - taxDetailsToCartId.getAmount(), - taxDetailsToCartId.getTaxName() - ); - cartTax.setTaxRate(String.valueOf(taxDetailsToCartId.getRate())); - cartTaxCollection.add(cartTax); - // Add adjustment taxes to cartItemAdjustments of cartItem and create CartTaxDto entries - // for all promotion adjustments. - if ( - taxDetailsToCartId.getItemizedPromotionTaxAmounts() != null && - !(taxDetailsToCartId.getItemizedPromotionTaxAmounts().isEmpty()) - ) - for (CartAdjustment cipaTax : taxDetailsToCartId.getItemizedPromotionTaxAmounts()) { - CartExtension.CartTax promoTax = new CartExtension.CartTax( - CartExtension.TaxTypeEnum.ESTIMATED, - cipaTax.getAmount(), - taxDetailsToCartId.getTaxName() - ); - promoTax.setTaxRate(String.valueOf(taxDetailsToCartId.getRate())); - CartExtension.cartItemPriceAdjustment adj = getAdjustmentById( - cartItem.getCartItemPriceAdjustments(), - cipaTax.getId() - ); - if (adj != null) { - adj.getCartTaxes().add(promoTax); - } + // Get the tax rates and tax amounts from an external service for all given products + Map dataFromExternalService = getTaxesFromStaticResponse( + cartItemById, + CartDeliveryGroup.getDeliverToAddress().getState(), + CartDeliveryGroup.getDeliverToAddress().getCountry(), + cart.getTaxType()); + + for (String cartItemId : dataFromExternalService.keySet()) { + TaxData taxDetailsToCartId = dataFromExternalService.get(cartItemId); + CartExtension.CartItem cartItem = cartItemById.get(cartItemId); + + addTaxesToCartItem(cartItem, taxDetailsToCartId); } + + } catch (Exception e) { + // For testing purposes, this example treats exceptions as user errors, which means they are + // displayed to the buyer user. In production, you probably want exceptions to be admin-type + // errors. In that case, throw the exception here and make sure that a notification system is + // in place to let the admin know that the error occurred. See the README section about error + // handling for details about how to create that notification. + throw new CalloutException('There was a problem with the request.'); } - } + return; + } - // If there are shipping items, add tax for them as well - for (String cartItemId : dataFromExternalServiceForShippingItems.keySet()) { - TaxDataFromExternalService taxDetailsToCartId = dataFromExternalServiceForShippingItems.get(cartItemId); - CartExtension.CartItem cartItem = shippingItemById.get(cartItemId); - boolean isCartItemModified = false; - // If there is any modification in unit price, delete existing and create new cart tax entries in cart item. - if (cartItem.getNetUnitPrice() != null && - cartItem.getNetUnitPrice() != taxDetailsToCartId.getNetUnitPrice()) { + private void addTaxesToCartItem(CartExtension.CartItem cartItem, TaxData taxData) { + if (cartItem.getCartTaxes().size() > 0) { + // this sample always has at most one, your integration might have several cartItem.getCartTaxes().remove(cartItem.getCartTaxes().get(0)); - isCartItemModified = true; } - if (cartItem.getCartTaxes() == null || - cartItem.getCartTaxes().isEmpty() || - isCartItemModified == true) { - cartItem.setNetUnitPrice(taxDetailsToCartId.getNetUnitPrice()); - cartItem.setGrossUnitPrice(taxDetailsToCartId.getGrossUnitPrice()); + if (cartItem.getCartTaxes() == null || cartItem.getCartTaxes().isEmpty()) { + cartItem.setNetUnitPrice(taxData.getNetUnitPrice()); + cartItem.setGrossUnitPrice(taxData.getGrossUnitPrice()); CartExtension.CartTaxList cartTaxCollection = cartItem.getCartTaxes(); CartExtension.CartTax cartTax = new CartExtension.CartTax( - CartExtension.TaxTypeEnum.ESTIMATED, - taxDetailsToCartId.getAmount(), - taxDetailsToCartId.getTaxName() - ); - cartTax.setTaxRate(String.valueOf(taxDetailsToCartId.getRate())); + CartExtension.TaxTypeEnum.ESTIMATED, + taxData.getAmount(), + taxData.getTaxName()); + cartTax.setTaxRate(String.valueOf(taxData.getRate())); cartTaxCollection.add(cartTax); } - } - } catch (Exception e) { - // For testing purposes, this example treats exceptions as user errors, which means they are - // displayed to the buyer user. In production, you probably want exceptions to be admin-type - // errors. In that case, throw the exception here and make sure that a notification system is - // in place to let the admin know that the error occurred. See the README section about error - // handling for details about how to create that notification. - throw new CalloutException('There was a problem with the request.'); } - return; - } - - // Verify if taxes from adjustments returned by external service and existing cart has changed. If - // returned true then that indicates that there was an adjustment change. - private Boolean VerifyAdjustmentUpdate( - CartExtension.CartItem cartItemDto, - TaxDataFromExternalService taxesFromExternalService - ) { - List ajustments = taxesFromExternalService.getItemizedPromotionTaxAmounts() == - null - ? new List() - : taxesFromExternalService.getItemizedPromotionTaxAmounts(); - - for (Integer i = (cartItemDto.getCartItemPriceAdjustments().size() - 1); i >= 0; i--) { - CartExtension.CartTaxList cartTaxes = cartItemDto.getCartItemPriceAdjustments() - .get(i) - .getCartTaxes(); - for (Integer j = (cartTaxes.size() - 1); j >= 0; j--) { - Boolean changedAdjTax = false; - for (Integer k = (ajustments.size() - 1); k >= 0; k--) { - if (cartTaxes.get(j).getAmount() == ajustments.get(k).getAmount()) - changedAdjTax = true; - } - if (changedAdjTax == false) - return false; - } - } - return true; - } - - // Get cartItemAdjustment based on its ID. - private CartExtension.cartItemPriceAdjustment getAdjustmentById( - CartExtension.cartItemPriceAdjustmentList cipaList, - String id - ) { - for (Integer i = (cipaList.size() - 1); i >= 0; i--) { - if (String.valueOf(cipaList.get(i).getId()) == id) - return cipaList.get(i); - } - return null; - } - - // This similartes a call to an external tax service. Change this function based on your external - // service. Transform tax data returned from service into cart ID to TaxDataFromExternalService - // map. - private Map getTaxesFromExternalService( - Map cartItemById, - String state, - String country, - CartExtension.TaxLocaleTypeEnum taxType - ) { - String requestURL = externalTaxHost+'/get-tax-rates-with-adjustments-post'; - String requestBody = - '{"state":"' + - state + - '", "country":"' + - country + - '", "taxType":"' + - taxType + - '", ' + - '"amountsBySKU":' + - JSON.serialize(cartItemById) + - '}'; - Http http = new Http(); - HttpRequest request = new HttpRequest(); - request.setEndpoint(requestURL); - request.setMethod('POST'); - request.setHeader('Content-Type', 'application/json'); - request.setBody(requestBody); - HttpResponse response = http.send(request); - - // If the request is successful, parse the JSON response. - if (response.getStatusCode() == 200) { - Map resultsFromExternalService = (Map) JSON.deserializeUntyped( - response.getBody() - ); - return populateTax(resultsFromExternalService); - } else { - throw new CalloutException( - 'There was a problem with the request. Error: ' + response.getStatusCode() - ); - } - } - - private Map populateTax(Map resultsFromExternalService){ - Map taxDetailsFromExternalService = new Map(); - for (String cartItemId : resultsFromExternalService.keySet()) { - Map rateAndAmountFromExternalService = (Map) resultsFromExternalService.get( - cartItemId - ); - List cipaList = (List) rateAndAmountFromExternalService.get( - 'itemizedPromotionTaxAmounts' - ); - List cipaObj = new List(); - - for (Object cipa : cipaList) { - cipaObj.add( - new CartAdjustment( - (String) ((Map) cipa).get('id'), - (Decimal) ((Map) cipa).get('taxAmount') - ) - ); - } - taxDetailsFromExternalService.put( - cartItemId, - new TaxDataFromExternalService( - (Decimal) rateAndAmountFromExternalService.get('rate'), - (Decimal) rateAndAmountFromExternalService.get('amount'), - (String) rateAndAmountFromExternalService.get('taxName'), - (Decimal) rateAndAmountFromExternalService.get('adjustmentTaxAmount'), - (Decimal) rateAndAmountFromExternalService.get('totalItemizedPromotionTaxAmount'), - cipaObj, - (Decimal) rateAndAmountFromExternalService.get('grossUnitPrice'), - (Decimal) rateAndAmountFromExternalService.get('netUnitPrice') - ) - ); - } - return taxDetailsFromExternalService; - - } - - private Map getTaxesFromStaticResponse(Map cartItemsMap, String state, String country, CartExtension.TaxLocaleTypeEnum taxType) { + private Map getTaxesFromStaticResponse(Map cartItemsMap, String state, String country, CartExtension.TaxLocaleTypeEnum taxType) { Double taxRate = 0.15; - String responseJson = '{'; - for (String key : cartItemsMap.keySet()) { - CartExtension.CartItem cartItem = cartItemsMap.get(key); - ID cartItemId = cartItem.getId(); - - Double amount = cartItem.getTotalAmount()==null ? 0.00 : cartItem.getTotalAmount(); - Double tierAdjustment = cartItem.getAdjustmentAmount()==null ? 0.00 : cartItem.getAdjustmentAmount(); - Double quantity = cartItem.getQuantity()==null ? 0.00 : cartItem.getQuantity(); + Map taxDetailsFromExternalService = new Map(); + for (String cartItemIdOrDeliveryGroupId : cartItemsMap.keySet()) { + CartExtension.CartItem cartItem = cartItemsMap.get(cartItemIdOrDeliveryGroupId); + String cartItemId = (cartItem.getId()==null) ? cartItemIdOrDeliveryGroupId : cartItem.getId(); - if(country == 'US') { - taxRate = 0.08; - String [] noSalesTaxUSStates = new String [] {'AK', 'DE', 'MT', 'NH', 'OR'}; - if (noSalesTaxUSStates.contains(state)) { - taxRate = 0.00; - } - } + Double amount = cartItem.getTotalPriceAfterAllAdjustments()==null ? cartItem.getTotalListPrice() : cartItem.getTotalPriceAfterAllAdjustments(); + Double quantity = cartItem.getQuantity(); - Double itemizedPromotionTax = 0.00; - Double [] itemizedPromotionTaxArr = new Double [] {}; Double netUnitPrice = 0.00; Double grossUnitPrice = 0.00; - Double multiplier = 0.00; - - if(taxType == CartExtension.TaxLocaleTypeEnum.GROSS ) { - multiplier = taxRate / (1 + taxRate); - } else { - multiplier = taxRate; - } - - Double cartItemTax = amount * multiplier; - Double tierAdjustmentTax = (tierAdjustment!=null ? tierAdjustment : 0.00) * multiplier; - - CartExtension.CartItemPriceAdjustmentList itemizedPromotions = cartItem.getCartItemPriceAdjustments(); - - String itemizedPromotionTaxResp = '['; - for(Integer i=0; i resultsFromStaticResponse = (Map) JSON.deserializeUntyped(responseJson); - return populateTax(resultsFromStaticResponse); - } - - // Structure to store the tax data retrieved from external service. This class simplifies our - // ability to access the data when storing it in Salesforce's CartTaxDto. - class TaxDataFromExternalService { - private Decimal rate; - private Decimal amount; - private String taxName; - private Decimal adjustmentTaxAmount; - private Decimal totalItemizedPromotionTaxAmount; - private List itemizedPromotionTaxAmounts; - private Decimal grossUnitPrice; - private Decimal netUnitPrice; - - public TaxDataFromExternalService() { - rate = 0.0; - amount = 0.0; - taxName = ''; - adjustmentTaxAmount = 0.0; - totalItemizedPromotionTaxAmount = 0.0; - itemizedPromotionTaxAmounts = null; - grossUnitPrice = 0.0; - netUnitPrice = 0.0; - } - - public TaxDataFromExternalService( - Decimal rateObj, - Decimal amountObj, - String taxNameObj, - Decimal adjustmentTaxAmountObj, - Decimal totalItemizedPromotionTaxAmountObj, - List itemizedPromotionTaxAmountsObj, - Decimal grossUnitPriceObj, - Decimal netUnitPriceObj - ) { - rate = rateObj; - amount = amountObj; - taxName = taxNameObj; - adjustmentTaxAmount = adjustmentTaxAmountObj; - totalItemizedPromotionTaxAmount = totalItemizedPromotionTaxAmountObj; - itemizedPromotionTaxAmounts = itemizedPromotionTaxAmountsObj; - grossUnitPrice = grossUnitPriceObj; - netUnitPrice = netUnitPriceObj; - } - - public Decimal getRate() { - return rate; - } - - public Decimal getAmount() { - return amount; - } - - public String getTaxName() { - return taxName; - } - - public Decimal getAdjustmentTaxAmount() { - return adjustmentTaxAmount; - } - - public Decimal getTotalItemizedPromotionTaxAmount() { - return totalItemizedPromotionTaxAmount; - } - - public List getItemizedPromotionTaxAmounts() { - return itemizedPromotionTaxAmounts; - } - - public Decimal getGrossUnitPrice() { - return grossUnitPrice; - } - - public Decimal getNetUnitPrice() { - return netUnitPrice; - } - } + return taxDetailsFromExternalService; + } + + // Structure to store the tax data retrieved from external service. This class simplifies our + // ability to access the data when storing it in Salesforce's CartTaxDto. + class TaxData { + private Decimal rate; + private Decimal amount; + private String taxName; + private Decimal grossUnitPrice; + private Decimal netUnitPrice; + + public TaxData( + Decimal rateObj, + Decimal amountObj, + String taxNameObj, + Decimal grossUnitPriceObj, + Decimal netUnitPriceObj + ) { + rate = rateObj; + amount = amountObj; + taxName = taxNameObj; + grossUnitPrice = grossUnitPriceObj; + netUnitPrice = netUnitPriceObj; + } - class CartAdjustment { - private String id; - private Decimal amount; + public Decimal getRate() { + return rate; + } - public CartAdjustment() { - id = ''; - amount = 0.0; - } + public Decimal getAmount() { + return amount; + } - public CartAdjustment(String idObj, Decimal taxAmountObj) { - id = idObj; - amount = taxAmountObj; - } + public String getTaxName() { + return taxName; + } - public String getId() { - return id; - } + public Decimal getGrossUnitPrice() { + return grossUnitPrice; + } - public Decimal getAmount() { - return amount; + public Decimal getNetUnitPrice() { + return netUnitPrice; + } } - } -} +} \ No newline at end of file diff --git a/commerce/domain/tax/cart/calculator/classes/TaxCartCalculatorSampleTest.cls b/commerce/domain/tax/cart/calculator/classes/TaxCartCalculatorSampleTest.cls new file mode 100644 index 0000000..2509106 --- /dev/null +++ b/commerce/domain/tax/cart/calculator/classes/TaxCartCalculatorSampleTest.cls @@ -0,0 +1,392 @@ +/** + * @description A Sample unit test for TaxCartCalculatorSample. + */ +@IsTest +public inherited sharing class TaxCartCalculatorSampleTest { + + private static final String CART_NAME = 'My Cart'; + private static final String ACCOUNT_NAME = 'My Account'; + private static final String WEBSTORE_NAME = 'My WebStore'; + private static final String DELIVERYGROUP_NAME = 'My Delivery Group'; + private static final String CART_ITEM1_NAME = 'My Cart Item 1'; + private static final String CART_ITEM2_NAME = 'My Cart Item 2'; + private static final String CART_ITEM3_NAME = 'My Cart Item 3'; + private static final String SKU1_NAME = 'My SKU 1'; + private static final String SKU2_NAME = 'My SKU 2'; + private static final String SKU3_NAME = 'My SKU 3'; + private static final Decimal ESTIMATED_PRICE = 350.00; + private static final Decimal ACTUAL_PRICE_SKU1 = 100.00; + private static final Decimal ACTUAL_PRICE_SKU2 = 200.00; + private static final Decimal ACTUAL_PRICE_SKU3 = 300.00; + + @IsTest + static void testCalculate_withEmptyDeliveryAddress() { + // Arrange + CartExtension.Cart cart = arrangeAndLoadCartWithSpecifiedStatusAndThreeItems(CartExtension.CartStatusEnum.ACTIVE); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + TaxCartCalculatorSample calculator = new TaxCartCalculatorSample(); + + // Act + Test.startTest(); + calculator.calculate(request); + Test.stopTest(); + + // Assert + cart = request.getCart(); + CartExtension.CartItemList cartItemCollection = cart.getCartItems(); + Assert.areEqual(0, cartItemCollection.get(0).getCartTaxes().size()); + } + + @IsTest + static void testCalculate_withEmptyCartItems() { + // Arrange + CartExtension.Cart cart = arrangeAndLoadCartWithNoCartItems(CartExtension.CartStatusEnum.ACTIVE); + CartExtension.CartDeliveryGroup deliveryGroup = cart.getCartDeliveryGroups().get(0); + deliveryGroup.setDeliverToStreet('newStreet'); + deliveryGroup.setDeliverToCity('newCity'); + deliveryGroup.setDeliverToState('Washington'); + deliveryGroup.setDeliverToCountry('US'); + deliveryGroup.setDeliverToPostalCode('987654'); + deliveryGroup.setDeliverToLatitude(48.1); + deliveryGroup.setDeliverToLongitude(33.2); + deliveryGroup.setDeliverToGeocodeAccuracy(null); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + TaxCartCalculatorSample calculator = new TaxCartCalculatorSample(); + + // Act + Test.startTest(); + calculator.calculate(request); + Test.stopTest(); + + // Assert + cart = request.getCart(); + CartExtension.CartItemList cartItemCollection = cart.getCartItems(); + Assert.areEqual(0, cartItemCollection.size()); + } + + @IsTest + static void testCalculate_withZeroPrice() { + // Arrange + CartExtension.Cart cart = arrangeAndLoadCartWithSpecifiedStatus(CartExtension.CartStatusEnum.ACTIVE); + CartExtension.CartDeliveryGroup deliveryGroup = cart.getCartDeliveryGroups().get(0); + deliveryGroup.setDeliverToStreet('newStreet'); + deliveryGroup.setDeliverToCity('newCity'); + deliveryGroup.setDeliverToState('Washington'); + deliveryGroup.setDeliverToCountry('US'); + deliveryGroup.setDeliverToPostalCode('987654'); + deliveryGroup.setDeliverToLatitude(48.1); + deliveryGroup.setDeliverToLongitude(33.2); + deliveryGroup.setDeliverToGeocodeAccuracy(null); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + TaxCartCalculatorSample calculator = new TaxCartCalculatorSample(); + + CartExtension.CartValidationOutputList cartValidationOutputCollection = cart.getCartValidationOutputs(); + CartExtension.CartValidationOutput cvo = new CartExtension.CartValidationOutput( + CartExtension.CartValidationOutputTypeEnum.TAXES, CartExtension.CartValidationOutputLevelEnum.ERROR); + cartValidationOutputCollection.add(cvo); + + // Act + Test.startTest(); + calculator.calculate(request); + Test.stopTest(); + + // Assert + cart = request.getCart(); + CartExtension.CartItemList cartItemCollection = cart.getCartItems(); + Assert.areEqual(0, cart.getCartValidationOutputs().size()); + Iterator cartItemCollectionIterator = cartItemCollection.iterator(); + while (cartItemCollectionIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemCollectionIterator.next(); + Assert.areEqual(0.00, cartItem.getNetUnitPrice()); + Assert.areEqual(0.00, cartItem.getGrossUnitPrice()); + } + } + + @IsTest + static void testCalculate_withDeliveryAddress() { + // Arrange + CartExtension.Cart cart = arrangeAndLoadCartWithSpecifiedStatus(CartExtension.CartStatusEnum.ACTIVE); + CartExtension.CartDeliveryGroup deliveryGroup = cart.getCartDeliveryGroups().get(0); + deliveryGroup.setDeliverToStreet('newStreet'); + deliveryGroup.setDeliverToCity('newCity'); + deliveryGroup.setDeliverToState('Washington'); + deliveryGroup.setDeliverToCountry('US'); + deliveryGroup.setDeliverToPostalCode('987654'); + deliveryGroup.setDeliverToLatitude(48.1); + deliveryGroup.setDeliverToLongitude(33.2); + deliveryGroup.setDeliverToGeocodeAccuracy(null); + cart.getCartItems().get(0).setTotalPrice(100.00); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + TaxCartCalculatorSample calculator = new TaxCartCalculatorSample(); + + // Act + Test.startTest(); + calculator.calculate(request); + Test.stopTest(); + + // Assert + cart = request.getCart(); + CartExtension.CartItemList cartItemCollection = cart.getCartItems(); + Iterator cartItemCollectionIterator = cartItemCollection.iterator(); + while (cartItemCollectionIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemCollectionIterator.next(); + Assert.areEqual(100.00, cartItem.getNetUnitPrice()); + Assert.areEqual(108.00, cartItem.getGrossUnitPrice()); + } + } + + @IsTest + static void testCalculate_withShippingChargeItem() { + // Arrange + CartExtension.Cart cart = arrangeAndLoadCartWithShippingChargeItem(CartExtension.CartStatusEnum.ACTIVE); + CartExtension.CartDeliveryGroup deliveryGroup = cart.getCartDeliveryGroups().get(0); + deliveryGroup.setDeliverToStreet('newStreet'); + deliveryGroup.setDeliverToCity('newCity'); + deliveryGroup.setDeliverToState('Washington'); + deliveryGroup.setDeliverToCountry('US'); + deliveryGroup.setDeliverToPostalCode('987654'); + deliveryGroup.setDeliverToLatitude(48.1); + deliveryGroup.setDeliverToLongitude(33.2); + deliveryGroup.setDeliverToGeocodeAccuracy(null); + cart.getCartItems().get(0).setTotalPrice(100.00); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + TaxCartCalculatorSample calculator = new TaxCartCalculatorSample(); + + // Act + Test.startTest(); + calculator.calculate(request); + Test.stopTest(); + + // Assert + cart = request.getCart(); + CartExtension.CartItemList cartItemCollection = cart.getCartItems(); + Assert.areEqual(2, cartItemCollection.size()); + } + + @IsTest + static void testCalculate_withNetPrice() { + // Arrange + CartExtension.Cart cart = arrangeAndLoadCartWithSpecifiedStatus(CartExtension.CartStatusEnum.ACTIVE); + CartExtension.CartDeliveryGroup deliveryGroup = cart.getCartDeliveryGroups().get(0); + deliveryGroup.setDeliverToStreet('newStreet'); + deliveryGroup.setDeliverToCity('newCity'); + deliveryGroup.setDeliverToState('Washington'); + deliveryGroup.setDeliverToCountry('US'); + deliveryGroup.setDeliverToPostalCode('987654'); + deliveryGroup.setDeliverToLatitude(48.1); + deliveryGroup.setDeliverToLongitude(33.2); + deliveryGroup.setDeliverToGeocodeAccuracy(null); + cart.getCartItems().get(0).setTotalPrice(100.00); + cart.getCartItems().get(0).setNetUnitPrice(200.00); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + TaxCartCalculatorSample calculator = new TaxCartCalculatorSample(); + + // Act + Test.startTest(); + calculator.calculate(request); + Test.stopTest(); + + // Assert + cart = request.getCart(); + CartExtension.CartItemList cartItemCollection = cart.getCartItems(); + Iterator cartItemCollectionIterator = cartItemCollection.iterator(); + while (cartItemCollectionIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemCollectionIterator.next(); + Assert.areEqual(100.00, cartItem.getNetUnitPrice()); + Assert.areEqual(108.00, cartItem.getGrossUnitPrice()); + } + } + + @IsTest + static void testCalculate_withPriceAdjustments() { + // Arrange + CartExtension.Cart cart = arrangeAndLoadCartWithAdjustments(CartExtension.CartStatusEnum.ACTIVE); + CartExtension.CartDeliveryGroup deliveryGroup = cart.getCartDeliveryGroups().get(0); + deliveryGroup.setDeliverToStreet('newStreet'); + deliveryGroup.setDeliverToCity('newCity'); + deliveryGroup.setDeliverToState('Washington'); + deliveryGroup.setDeliverToCountry('US'); + deliveryGroup.setDeliverToPostalCode('987654'); + deliveryGroup.setDeliverToLatitude(48.1); + deliveryGroup.setDeliverToLongitude(33.2); + deliveryGroup.setDeliverToGeocodeAccuracy(null); + + CartExtension.CartItemPriceAdjustment newItemPriceAdjustment = new CartExtension.CartItemPriceAdjustment + (CartExtension.CartAdjustmentTargetTypeEnum.ITEM, 1, + CartExtension.PriceAdjustmentSourceEnum.PROMOTION, + CartExtension.AdjustmentTypeEnum.ADJUSTMENT_AMOUNT, -2, '0c8RO0000005qNPYAY'); + newItemPriceAdjustment.setPriority(2); + newItemPriceAdjustment.setAdjustmentValue(3); + CartExtension.CartItemPriceAdjustmentList cartItemPriceAdjustments = cart.getCartItems().get(0).getCartItemPriceAdjustments(); + cartItemPriceAdjustments.add(newItemPriceAdjustment); + + cart.getCartItems().get(0).setTotalPrice(100.00); + cart.getCartItems().get(0).setNetUnitPrice(200.00); + CartExtension.CartCalculateCalculatorRequest request = new CartExtension.CartCalculateCalculatorRequest(cart, CartExtension.OptionalBuyerActionDetails.empty()); + TaxCartCalculatorSample calculator = new TaxCartCalculatorSample(); + + // Act + Test.startTest(); + calculator.calculate(request); + Test.stopTest(); + + // Assert + cart = request.getCart(); + CartExtension.CartItemList cartItemCollection = cart.getCartItems(); + Iterator cartItemCollectionIterator = cartItemCollection.iterator(); + while (cartItemCollectionIterator.hasNext()) { + CartExtension.CartItem cartItem = cartItemCollectionIterator.next(); + Assert.areEqual(33.666666666666664, cartItem.getNetUnitPrice()); + Assert.areEqual(36.36, cartItem.getGrossUnitPrice()); + } + } + + /** + * @description Create and return a WebCart with the specified status and 3 items. + * + * @param cartStatus The status of the cart. + * + * @return <> + */ + private static ID arrangeCartWithSpecifiedStatus(CartExtension.CartStatusEnum cartStatus) { + Account account = new Account(Name = ACCOUNT_NAME); + insert account; + + WebStore webStore = new WebStore(Name = WEBSTORE_NAME, OptionsCartCalculateEnabled = true); + insert webStore; + + WebCart webCart = new WebCart( + Name = CART_NAME, + WebStoreId = webStore.Id, + AccountId = account.Id, + Status = cartStatus.name()); + insert webCart; + return webCart.Id; + } + + private static List arrangeThreeCartItems(ID cartId) { + CartDeliveryGroup deliveryGroup = new CartDeliveryGroup(Name = DELIVERYGROUP_NAME, CartId = cartId); + insert deliveryGroup; + + CartItem cartItem1 = new CartItem( + Name = CART_ITEM1_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 3, + SKU = SKU1_NAME, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem1; + + CartItem cartItem2 = new CartItem( + Name = CART_ITEM2_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 3, + SKU = SKU2_NAME, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem2; + + CartItem cartItem3 = new CartItem( + Name = CART_ITEM3_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 3, + SKU = SKU3_NAME, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem3; + return new List{cartItem1.Id, cartItem2.Id, cartItem3.Id}; + } + + private static CartExtension.Cart arrangeAndLoadCartWithSpecifiedStatusAndThreeItems(CartExtension.CartStatusEnum cartStatus) { + Id cartId = arrangeCartWithSpecifiedStatus(cartStatus); + arrangeThreeCartItems(cartId); + return CartExtension.CartTestUtil.getCart(cartId); + } + + private static List arrangeOneCartItemsWithShippingChargeType(ID cartId) { + CartDeliveryGroup deliveryGroup = new CartDeliveryGroup(Name = DELIVERYGROUP_NAME, CartId = cartId); + insert deliveryGroup; + + CartItem cartItem1 = new CartItem( + Name = CART_ITEM1_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 3, + SKU = SKU1_NAME, + Type = CartExtension.SalesItemTypeEnum.CHARGE.name()); + insert cartItem1; + + CartItem cartItem2 = new CartItem( + Name = CART_ITEM2_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 3, + SKU = SKU2_NAME, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem2; + + return new List{cartItem1.Id, cartItem2.Id}; + } + + private static CartExtension.Cart arrangeAndLoadCartWithShippingChargeItem(CartExtension.CartStatusEnum cartStatus) { + Id cartId = arrangeCartWithSpecifiedStatus(cartStatus); + arrangeOneCartItemsWithShippingChargeType(cartId); + return CartExtension.CartTestUtil.getCart(cartId); + } + + private static List arrangeOneCartItemWithPriceAdjustments(ID cartId) { + CartDeliveryGroup deliveryGroup = new CartDeliveryGroup(Name = DELIVERYGROUP_NAME, CartId = cartId); + insert deliveryGroup; + + CartItem cartItem = new CartItem( + Name = CART_ITEM1_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 3, + SKU = SKU1_NAME, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem; + return new List{cartItem.Id}; + } + + private static CartExtension.Cart arrangeAndLoadCartWithAdjustments(CartExtension.CartStatusEnum cartStatus) { + Id cartId = arrangeCartWithSpecifiedStatus(cartStatus); + arrangeOneCartItemWithPriceAdjustments(cartId); + return CartExtension.CartTestUtil.getCart(cartId); + } + + private static List arrangeDeliveryGroup(ID cartId) { + CartDeliveryGroup deliveryGroup = new CartDeliveryGroup(Name = DELIVERYGROUP_NAME, CartId = cartId); + insert deliveryGroup; + return new List{}; + } + + private static CartExtension.Cart arrangeAndLoadCartWithNoCartItems(CartExtension.CartStatusEnum cartStatus) { + Id cartId = arrangeCartWithSpecifiedStatus(cartStatus); + arrangeDeliveryGroup(cartId); + return CartExtension.CartTestUtil.getCart(cartId); + } + + private static CartExtension.Cart arrangeAndLoadCartWithSpecifiedStatus(CartExtension.CartStatusEnum cartStatus) { + Id cartId = arrangeCartWithSpecifiedStatus(cartStatus); + arrangeCartItemsWithDeliveryAddress(cartId); + return CartExtension.CartTestUtil.getCart(cartId); + } + + private static List arrangeCartItemsWithDeliveryAddress(ID cartId) { + CartDeliveryGroup deliveryGroup = new CartDeliveryGroup(Name = DELIVERYGROUP_NAME, CartId = cartId); + insert deliveryGroup; + + CartItem cartItem1 = new CartItem( + Name = CART_ITEM1_NAME, + CartId = cartId, + CartDeliveryGroupId = deliveryGroup.Id, + Quantity = 1, + SKU = SKU1_NAME, + Type = CartExtension.SalesItemTypeEnum.PRODUCT.name()); + insert cartItem1; + + return new List{cartItem1.Id}; + } + +} diff --git a/commerce/domain/tax/service/classes/TaxServiceExtensionResolverSample.cls b/commerce/domain/tax/service/classes/TaxServiceExtensionResolverSample.cls index 5ba0afe..1ba6a9d 100644 --- a/commerce/domain/tax/service/classes/TaxServiceExtensionResolverSample.cls +++ b/commerce/domain/tax/service/classes/TaxServiceExtensionResolverSample.cls @@ -16,7 +16,7 @@ // This must implement the commercestoretax.TaxService class in order to be processed by the tax service flow. // and it must implement the CommerceExtension.ResolutionStrategy in order to work as a extension resolver and get the different locales and resolutions. -public class TaxServiceExtensionResolverSample extends commercestoretax.TaxService implements CommerceExtension.ResolutionStrategy { +public with sharing class TaxServiceExtensionResolverSample extends commercestoretax.TaxService implements CommerceExtension.ResolutionStrategy { public CommerceExtension.Resolution resolve() { // The Sample Extension Provider registered with developer name as 'tax_extension_provider_for_us' will be selected for execution for en_US locale if(CommerceExtension.ExtensionInfo.getLocaleString() == 'en_US') { diff --git a/commerce/domain/tax/service/classes/TaxServiceSample.cls b/commerce/domain/tax/service/classes/TaxServiceSample.cls index 283a280..88dbc88 100644 --- a/commerce/domain/tax/service/classes/TaxServiceSample.cls +++ b/commerce/domain/tax/service/classes/TaxServiceSample.cls @@ -1,26 +1,19 @@ -// This sample is for the situation when the tax behavior needs to be extended or overridden via the extension point for Salesforce Internal Tax Api. -// For Salesforce Internal Tax calculation, please see the corresponding documentation. +// If your tax calculation needs to deal with cart's custom fields or any other complex logic that requires direct access to cart, please extend +// the CartExtension.TaxCartCalculator instead. + +// If you plan to support tax calculations for subscription products, you must implement the commercestoretax.TaxService. // Your custom apex class must be linked to the tax extension point and then the integration must be linked to the web store via appropriate setup. // For more information related to that, please see the corresponding documentation. -// This must implement the commercestoretax.TaxService class in order to be processed by the tax service flow. -public class TaxServiceExtensionSample extends commercestoretax.TaxService { - // You MUST change this to be your service. - // and add the host in Setup | Security | Remote site settings. - private static String httpHost = 'https://example.com'; - - // If you are making valid external service call, make this flag as true. - private static Boolean useHTTPService = false; - - // We will default the tax type to Gross in this example. This can be changed to Net if required. - private static String taxType = 'Gross'; +// Disclaimer: the code listed here is a sample that hasn't been tested for production use. Always test your code before releasing to production. +public with sharing class TaxServiceExtensionSample extends commercestoretax.TaxService { + + Map taxCodeToRate = new Map{'Beverages' => 0.25, 'Shipping' => 0.1}; - // Override processGetStoreTaxesInfo method in order to change behavior of the tax treatments applied for a product. - // - // Fields that can be overridden are: - // Header level - tax locale type: GROSS or NET, error. - // Item level - tax rate percentage, tax treatment name, tax treatment description, priority, country iso code, state iso code, error. + // The method is used for displaying tax-related information on PDP + // If you are using an external Tax Service, you most likely don't need to override the method + // Disclaimer: the code listed here is a sample that hasn't been tested for production use. Always test your code before releasing to production. public override commercestoretax.GetStoreTaxesInfoResponse processGetStoreTaxesInfo(commercestoretax.GetStoreTaxesInfoRequest request) { try { // Call the default internal tax implementation with either original request or modified request. @@ -29,7 +22,6 @@ public class TaxServiceExtensionSample extends commercestoretax.TaxService { // Override tax rate percentage by increasing them by a fixed amount. Also, let us override treatment name and // description by appending a prefix to them in case customers use a different naming convention. Double fixedAmountIncrease = 2; - String prefix = 'Customer_'; commercestoretax.ProductIdCollection productIds = request.getProductIds(); Map storeTaxesInfoContainerMap = response.getTaxesInfo(); for(Integer i = 0; i < productIds.size(); i++){ @@ -39,8 +31,6 @@ public class TaxServiceExtensionSample extends commercestoretax.TaxService { for (Integer j = 0; j < storeTaxesInfoCollection.size(); j++) { commercestoretax.StoreTaxesInfo storeTaxesInfo = storeTaxesInfoCollection.get(j); storeTaxesInfo.setTaxRatePercentage(storeTaxesInfo.getTaxRatePercentage() + fixedAmountIncrease); - storeTaxesInfo.setTaxTreatmentName(appendField(prefix, storeTaxesInfo.getTaxTreatmentName())); - storeTaxesInfo.setTaxTreatmentDescription(appendField(prefix, storeTaxesInfo.getTaxTreatmentDescription())); } } @@ -61,107 +51,56 @@ public class TaxServiceExtensionSample extends commercestoretax.TaxService { } } - // Override processCalculateTaxes method in order to change behavior of the tax calculations for line items in Salesforce native tax API. + // This is the method that performs tax calculations. + // + // You should avoid making any calls for cart/cart item retrival from here since at this stage the cart isn't committed. // - // Fields that can be overridden are: - // Header level - tax locale type, total tax amount, class taxes, error. - // Item level - line id, product id, net unit price, total line tax amount, total adjustment tax amount, - // total tiered adjustment tax amount, tax adjustments, total price tax amount, error, tax info. + // Disclaimer: the code listed here is a sample that hasn't been tested for production use. Always test your code before releasing to production. public override commercestoretax.CalculateTaxesResponse processCalculateTaxes(commercestoretax.CalculateTaxesRequest request2) { - String prefix = 'Customer_'; commercestoretax.CalculateTaxesRequestItemGroupCollection calculateTaxesRequestItemGroupCollection = request2.getLineItemGroups(); - commercestoretax.CalculateTaxesResponse response = new commercestoretax.CalculateTaxesResponse(commercestoretax.TaxLocaleType.GROSS); + commercestoretax.CalculateTaxesResponse response = new commercestoretax.CalculateTaxesResponse(commercestoretax.TaxLocaleType.Net); - // Customer can choose to exempt the taxes for some of the products. - // Exempted products list here are the products the Customer choose to exempt tax calculation. - List taxExemptedProducts = getTaxExemptedProducts(); try { + // the request might have multiple delivery groups (for example, in case of split shipments) for (Integer i = 0; i < calculateTaxesRequestItemGroupCollection.size(); i++) { commercestoretax.CalculateTaxesRequestItemGroup itemGroup = calculateTaxesRequestItemGroupCollection.get(i); - List taxableLineItems = new List(); commercestoretax.Address shippingAddress = itemgroup.getShipToAddress(); - // Customer may be eligible to collect taxes in specific countries and states. - if (!allowTaxCollection(shippingAddress.getCountry(), shippingAddress.getState())) { - // Not eligible to collect taxes for the country and state specified in the request, throw exception - throw new InvalidParameterValueException('commercestoretax.CalculateTaxesRequest.CalculateTaxesRequestItemGroup.shippingAddress.Country', - 'Unsupported country and state specified.'); - } + // You might want to do address validation here // Customers may choose to exempt tax for some products. Filter out all taxable products. commercestoretax.CalculateTaxesRequestLineItemCollection lineItemCollection = itemGroup.getLineItems(); + + List taxableItems = new List(); for (Integer j=0; j < lineItemCollection.size(); j++) { - commercestoretax.CalculateTaxesRequestLineItem lineItem = lineItemCollection.get(j); - if (!taxExemptedProducts.contains(lineItem.getProductId())) { - // Taxable product - taxableLineItems.add(lineItem); - } else { - // Non-Taxable product, so ignore tax calculation for this. - response.addCalculateTaxesResponseLineItem(getLineItemResponseWithEmptyTaxValues(lineItem)); - } + taxableItems.add(lineItemCollection.get(j)); + } + // shipping charge comes from a separate field and is not a part of LineItems. + if (itemGroup.getShippingLineItem() != null) { + commercestoretax.CalculateTaxesRequestLineItem shippingLine = itemGroup.getShippingLineItem(); + + // Tax Code value on shipping charge isn't passed to the APEX code in the latest implementation. + shippingLine.setTaxCode('Shipping'); + taxableItems.add(shippingLine); } // Fetch the Tax Calculation data from external service. Customer can make any external service call to // fetch the information. In this example, we use static data. - Map dataFromService = null; - if (useHTTPService) { - dataFromService = getTaxCalculationFromExternalService(taxableLineItems, shippingAddress.getCountry(), shippingAddress.getState()); - } else { - dataFromService = getTaxCalculationFromStaticResponse(taxableLineItems, shippingAddress.getCountry(), shippingAddress.getState()); - } + Map dataFromService = getTaxCalculationFromStaticResponse(taxableItems, shippingAddress.getCountry(), shippingAddress.getState()); // Populate response from tax calculation data received from external service Decimal totalTaxAmount = 0.00; - for (Integer j=0; j < taxableLineItems.size(); j++) { - commercestoretax.CalculateTaxesRequestLineItem requestLineItem = taxableLineItems.get(j); - - // External service may not return the tax calculation data for some products, set error response for this. - if (dataFromService == null || dataFromService.get(requestLineItem.getProductId()) == null) { - commercestoretax.CalculateTaxesResponseLineItem lineItemResponse = new commercestoretax.CalculateTaxesResponseLineItem(); - lineItemResponse.setError('Error in calculating taxes for this product.', 'Erreur dans le calcul des taxes pour ce produit.'); - response.addCalculateTaxesResponseLineItem(lineItemResponse); - continue; - } + for (Integer j=0; j < taxableItems.size(); j++) { + commercestoretax.CalculateTaxesRequestLineItem requestLineItem = taxableItems.get(j); // Populate Line item response from the external tax data. - TaxCalculationDataFromExternalService taxCalculationData = dataFromService.get(requestLineItem.getProductId()); - commercestoretax.CalculateTaxesResponseLineItem lineItemResponse = new commercestoretax.CalculateTaxesResponseLineItem(); - lineItemResponse.setLineId(requestLineItem.getLineId()); - lineItemResponse.setProductId(requestLineItem.getProductId()); - lineItemResponse.setNetUnitPrice(taxCalculationData.getNetUnitPrice()); - lineItemResponse.setTotalLineTaxAmount(taxCalculationData.getTotalLineTaxAmount()); - lineItemResponse.setTotalPriceTaxAmount(taxCalculationData.getTotalPriceTaxAmount()); - totalTaxAmount = totalTaxAmount + lineItemResponse.getTotalPriceTaxAmount(); - lineItemResponse.setTotalTieredAdjustmentTaxAmount(taxCalculationData.getTotalTieredAdjTaxAmount()); - lineItemResponse.setTotalAdjustmentTaxAmount(taxCalculationData.getTotalAdjTaxAmount()); - - // In this example, we assume that the product is only configured for country level taxes. - // There can be more than one taxInfo when both country and state level taxes are configured for the product. - commercestoretax.TaxInfo taxInfo = new commercestoretax.TaxInfo(shippingAddress.getCountry(), shippingAddress.getState(), 1, - taxCalculationData.getTaxRate(), - taxCalculationData.getTaxName(), - taxCalculationData.getTaxTreatmentDesc(), - taxCalculationData.getTotalLineTaxAmount()); - lineItemResponse.addTaxInfo(taxInfo); - - // Populate Tax Adjustments - Map taxCalculationAdjustmentData = taxCalculationData.getTaxAdjustments(); - if (requestLineItem.getAdjustments() != null) { - for (Integer k=0; k > requestLineItem.getAdjustments().size(); k++) { - commercestoretax.LineAdjustment requestLineAdjustment = requestLineItem.getAdjustments().get(k); - TaxAdjustmentData taxAdjustmentData = taxCalculationAdjustmentData.get(requestLineAdjustment.getId()); - commercestoretax.TaxAdjustment taxAdjustmentResponse = new commercestoretax.TaxAdjustment(commercestoretax.TaxAdjustmentType.PROMOTIONAL); - taxAdjustmentResponse.setId(requestLineAdjustment.getId()); - taxAdjustmentResponse.setAdjustmentTaxAmount(taxAdjustmentData.getAmount()); - lineItemResponse.addTaxAdjustment(taxAdjustmentResponse); - } - } + TaxCalculationDataFromExternalService taxCalculationData = dataFromService.get(requestLineItem.getLineId()); + commercestoretax.CalculateTaxesResponseLineItem lineItemResponse = getLineItemResponseRepresentation(requestLineItem, taxCalculationData, shippingAddress); + response.addCalculateTaxesResponseLineItem(lineItemResponse); - - // Customer can choose to modify tax treatment name and description coming from external service. - taxInfo.setTaxTreatmentName(appendField(prefix, taxCalculationData.getTaxName())); - taxInfo.setTaxTreatmentDescription(appendField(prefix, taxCalculationData.getTaxTreatmentDesc())); + totalTaxAmount = totalTaxAmount + lineItemResponse.getTotalPriceTaxAmount(); } + response.setTotalTaxAmount(totalTaxAmount); } return response; @@ -173,148 +112,72 @@ public class TaxServiceExtensionSample extends commercestoretax.TaxService { throw new CalloutException('There was a problem with the request.'); } } + + private commercestoretax.CalculateTaxesResponseLineItem getLineItemResponseRepresentation(commercestoretax.CalculateTaxesRequestLineItem requestLineItem, TaxCalculationDataFromExternalService taxCalculationData, commercestoretax.Address shippingAddress) { + commercestoretax.CalculateTaxesResponseLineItem lineItemResponse = new commercestoretax.CalculateTaxesResponseLineItem(); + lineItemResponse.setLineId(requestLineItem.getLineId()); + lineItemResponse.setProductId(requestLineItem.getProductId()); + lineItemResponse.setNetUnitPrice(taxCalculationData.getNetUnitPrice()); + lineItemResponse.setTotalLineTaxAmount(taxCalculationData.getTotalLineTaxAmount()); + lineItemResponse.setTotalPriceTaxAmount(taxCalculationData.getTotalPriceTaxAmount()); + + // In this example, we assume that the product is only configured for country level taxes. + // There can be more than one taxInfo when both country and state level taxes are configured for the product. + commercestoretax.TaxInfo taxInfo = new commercestoretax.TaxInfo(shippingAddress.getCountry(), shippingAddress.getState(), 1, + taxCalculationData.getTaxRate(), + taxCalculationData.getTaxName(), + taxCalculationData.getTaxTreatmentDesc(), + taxCalculationData.getTotalLineTaxAmount()); + lineItemResponse.addTaxInfo(taxInfo); + + return lineItemResponse; + } // This is similar a call to an external tax service. For testing purpose, this function uses in-place logic // to populate the tax data. private Map getTaxCalculationFromStaticResponse(List lineItems, String country, String state) { + + // this ideally should be coming from the request, but it doesn't at this stage + String taxType = 'Net'; Map taxCalculationData = new Map(); for (Integer i=0; i < lineItems.size(); i++) { commercestoretax.CalculateTaxesRequestLineItem lineItem = lineItems.get(i); - Double taxRate = 0.15; - Decimal amount = lineItem.getTotalPrice() == null ? 0.00 : lineItem.getTotalPrice(); - if (country == 'US') { - taxRate = 0.08; - String [] noSalesTaxUSStates = new String [] {'AK', 'DE', 'MT', 'NH', 'OR'}; - if (noSalesTaxUSStates.contains(state)) { - taxRate = 0.00; - } - } - Decimal itemizedPromotionTax = 0.00; - Decimal itemizedTierTax = 0.00; + Decimal totalPrice = lineItem.getTotalPrice(); Decimal netUnitPrice = 0.00; Decimal quantity = lineItem.getQuantity(); Double multiplier = 0.00; + String lineTaxCode = lineItem.getTaxCode(); + Double taxRate = taxCodeToRate.get(lineTaxCode); if(taxType == 'Gross') { multiplier = taxRate / (1 + taxRate); } else { multiplier = taxRate; } - Decimal lineItemTax = amount * multiplier; - - Map adjustmentDataMap = null; - commercestoretax.LineAdjustmentCollection lineAdjCollection = lineItem.getAdjustments(); - if (lineAdjCollection != null && lineAdjCollection.size() > 0) { - adjustmentDataMap = new Map(); - for (Integer j=0; j < lineAdjCollection.size(); j++) { - commercestoretax.LineAdjustment lineAdjustment = lineAdjCollection.get(j); - Decimal itemTaxAmount = roundAmount((lineAdjustment.getAmount()!=null ? lineAdjustment.getAmount() : 0.00) * multiplier); - TaxAdjustmentData adjData = new TaxAdjustmentData(lineAdjustment.getId(), itemTaxAmount); - if (lineAdjustment.getType().equals(commercestoretax.TaxAdjustmentType.PROMOTIONAL.name())) { - itemizedPromotionTax = itemizedPromotionTax + itemTaxAmount; - } else { - itemizedTierTax = itemizedTierTax + itemTaxAmount; - } - adjustmentDataMap.put(lineAdjustment.getId(), adjData); - } - } + Decimal lineItemTax = totalPrice * multiplier; + if (taxType == 'Gross') { - netUnitPrice = (amount - lineItemTax) / quantity; + netUnitPrice = (totalPrice - lineItemTax) / quantity; } else { - netUnitPrice = amount / quantity; + netUnitPrice = totalPrice / quantity; } - taxCalculationData.put(lineItem.getProductId(), new TaxCalculationDataFromExternalService(taxRate, 'VAT', roundAmount(netUnitPrice), - roundAmount(lineItemTax), roundAmount(itemizedPromotionTax), roundAmount(itemizedTierTax), - roundAmount(lineItemTax + itemizedPromotionTax + itemizedTierTax), - 'VAT description', adjustmentDataMap)); + taxCalculationData.put(lineItem.getLineId(), new TaxCalculationDataFromExternalService(taxRate, 'VAT', roundAmount(netUnitPrice), + roundAmount(lineItemTax), roundAmount(lineItemTax))); } return taxCalculationData; } - // This function makes an external service call to get the response and populate the map. - // You should replace the httpHost with correct endpoint for this to work. - private Map getTaxCalculationFromExternalService(List lineItems, - String country, String state) { - - // Ensure that your service(httpHost) has this API end point. If not, change it to appropriate API end point on your service. - String requestURL = httpHost + '/get-tax-rates'; - - String requestBody = '{"state":"' + state + '", "country":"' + country + '", "taxType":"' + taxType + '", ' + '"lineItems":' + JSON.serialize(lineItems)+'}'; - Http http = new Http(); - HttpRequest request = new HttpRequest(); - request.setEndpoint(requestURL); - request.setMethod('POST'); - request.setHeader('Content-Type', 'application/json'); - request.setBody(requestBody); - HttpResponse response = http.send(request); - - // If the request is successful, parse the JSON response. We assume that external service is returning - // tax data for all the line items. If not, you can adjust the logic below to handle the corner cases. - if (response.getStatusCode() == 200) { - Map resultsFromExternalService = (Map) JSON.deserializeUntyped(response.getBody()); - Map taxCalculationData = new Map(); - for (Integer i=0; i < lineItems.size(); i++){ - commercestoretax.CalculateTaxesRequestLineItem lineItem = lineItems.get(i); - Map lineItemTaxData = (Map) resultsFromExternalService.get(lineItem.getProductId()); - taxCalculationData.put(lineItem.getProductId(), new TaxCalculationDataFromExternalService((Double) lineItemTaxData.get('taxRate'), - (String) lineItemTaxData.get('taxName'), - (Decimal) lineItemTaxData.get('netUnitPrice'), - (Decimal) lineItemTaxData.get('totalLineTaxAmount'), - (Decimal) lineItemTaxData.get('totalAdjTaxAmount'), - (Decimal) lineItemTaxData.get('totalTieredAdjTaxAmount'), - (Decimal) lineItemTaxData.get('totalPriceTaxAmount'), - (String) lineItemTaxData.get('taxTreatmentDesc'), - null)); - } - return taxCalculationData; - } else if(response.getStatusCode() == 404) { - throw new CalloutException ('404. You must create a sample application or add your own service which returns a valid response'); - } else { - throw new CalloutException ('There was a problem with the request. Error: ' + response.getStatusCode()); - } - } - - private Boolean allowTaxCollection(String country, String state) { - // Let us assume customer is allowed to collect taxes in Unites States only. - if (country == 'US') { - return true; - } - return false; - } - - // Gets list of tax exempted products. - private List getTaxExemptedProducts() { - List taxExemptedProducts = new List(); - taxExemptedProducts.add('productId1'); - taxExemptedProducts.add('productId2'); - // Customers can add the other exempted product Ids here. - return taxExemptedProducts; - } - // Appends a String prefix to the field specified. private String appendField(String prefix, String field){ // Customers can easily change the string IDs returned by Salesforce Internal Tax API return prefix + field; } - // Gets CalculateTaxesResponseLineItem response object with tax values as zero. - private commercestoretax.CalculateTaxesResponseLineItem getLineItemResponseWithEmptyTaxValues(commercestoretax.CalculateTaxesRequestLineItem lineItem) { - commercestoretax.CalculateTaxesResponseLineItem lineItemResponse = new commercestoretax.CalculateTaxesResponseLineItem(); - lineItemResponse.setLineId(lineItem.getLineId()); - lineItemResponse.setProductId(lineItem.getProductId()); - lineItemResponse.setNetUnitPrice(lineItem.getUnitPrice()); - lineItemResponse.setTotalLineTaxAmount(0.00); - lineItemResponse.setTotalPriceTaxAmount(0.00); - lineItemResponse.setTotalTieredAdjustmentTaxAmount(0.00); - lineItemResponse.setTotalAdjustmentTaxAmount(0.00); - return lineItemResponse; - } - // This function uses scale of 2 and rounding mode as System.RoundingMode.HALF_DOWN. // This should be overridden by the customer based on their requirements. private Decimal roundAmount(Decimal amount) { @@ -327,35 +190,25 @@ public class TaxServiceExtensionSample extends commercestoretax.TaxService { private String taxName; private Decimal netUnitPrice; private Decimal totalLineTaxAmount; - private Decimal totalAdjTaxAmount; - private Decimal totalTieredAdjTaxAmount; private Decimal totalPriceTaxAmount; private String taxTreatmentDesc; - private Map taxAdjustments; public TaxCalculationDataFromExternalService() { this.taxRate = 0.0; this.taxName = ''; this.netUnitPrice = 0.0; this.totalLineTaxAmount = 0.0; - this.totalAdjTaxAmount = 0.0; - this.totalTieredAdjTaxAmount = 0.0; this.totalPriceTaxAmount = 0.0; this.taxTreatmentDesc = ''; } public TaxCalculationDataFromExternalService(Double taxRate, String taxName, Decimal netUnitPrice, Decimal totalLineTaxAmount, - Decimal totalAdjTaxAmount, Decimal totalTieredAdjTaxAmount, Decimal totalPriceTaxAmount, - String taxTreatmentDesc, Map taxAdjustments) { + Decimal totalPriceTaxAmount) { this.taxRate = taxRate; this.taxName = taxName; this.netUnitPrice = netUnitPrice; this.totalLineTaxAmount = totalLineTaxAmount; - this.totalAdjTaxAmount = totalAdjTaxAmount; - this.totalTieredAdjTaxAmount = totalTieredAdjTaxAmount; this.totalPriceTaxAmount = totalPriceTaxAmount; - this.taxTreatmentDesc = taxTreatmentDesc; - this.taxAdjustments = taxAdjustments; } public Double getTaxRate() { @@ -374,14 +227,6 @@ public class TaxServiceExtensionSample extends commercestoretax.TaxService { return totalLineTaxAmount; } - public Decimal getTotalAdjTaxAmount() { - return totalAdjTaxAmount; - } - - public Decimal getTotalTieredAdjTaxAmount() { - return totalTieredAdjTaxAmount; - } - public Decimal getTotalPriceTaxAmount() { return totalPriceTaxAmount; } @@ -389,33 +234,5 @@ public class TaxServiceExtensionSample extends commercestoretax.TaxService { public String getTaxTreatmentDesc() { return taxTreatmentDesc; } - - public Map getTaxAdjustments() { - return taxAdjustments; - } - } - - // Structure to store the Tax Adjustment Calculation data retrieved from external service - class TaxAdjustmentData { - private String id; - private Decimal amount; - - public TaxAdjustmentData() { - id = ''; - amount = 0.0; - } - - public TaxAdjustmentData(String idObj, Decimal taxAmountObj) { - id = idObj; - amount = taxAmountObj; - } - - public String getId() { - return id; - } - - public Decimal getAmount() { - return amount; - } } -} +} \ No newline at end of file diff --git a/commerce/endpoint/cart/CartItemCollectionExtensionSample.cls b/commerce/endpoint/cart/CartItemCollectionExtensionSample.cls new file mode 100644 index 0000000..34a2426 --- /dev/null +++ b/commerce/endpoint/cart/CartItemCollectionExtensionSample.cls @@ -0,0 +1,59 @@ +/** + * Sample extension for enforcing quantity constraints on a cart item while adding an item to the cart. + * This corresponds to the endpoint /commerce/webstores/${webstoreId}/carts/${activeCartOrId}/cart-items + * and is identified by the EPN Commerce_Endpoint_Cart_ItemCollection for registration/mapping. + */ +public with sharing class CartItemCollectionExtensionSample extends ConnectApi.BaseEndpointExtension { + + // Define a constant for the maximum quantity allowed + private static final Integer MAX_QUANTITY = 100; + + /** + * Overrides the beforePost method to enforce a maximum quantity of a cart item. + * + * @param request The endpoint extension request containing cart item data. + * @return The modified endpoint extension request with updated quantity. + */ + public override ConnectApi.EndpointExtensionRequest beforePost(ConnectApi.EndpointExtensionRequest request) { + System.debug('We are in the beforePost entry method of Commerce_Endpoint_Cart_ItemCollection extension'); + + /** + * Retrieve the cart item input from the request. This is a deep copy. + * The parameter name can be found in the documentation: + * https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/extensions.html + */ + ConnectApi.CartItemInput cartItemInput = (ConnectApi.CartItemInput) request.getParam('cartItemInput'); + + // Check if cartItemInput is not null + if (cartItemInput != null) { + // Retrieve the quantity from the cart item input + String quantityStr = cartItemInput.getQuantity(); + + // Check if quantity is not null + if (quantityStr != null) { + try { + // Convert quantity from String to Integer + Integer quantity = Integer.valueOf(quantityStr); + + // Enforce the maximum quantity + if (quantity > MAX_QUANTITY) { + quantity = MAX_QUANTITY; + // Set the enforced quantity back to the cart item input + cartItemInput.setQuantity(quantity.toString()); + } + } catch (Exception e) { + throw new InvalidFormatException('Invalid quantity format: ' + quantityStr); + } + } + + /** + * Update the cart item input parameter in the request. + * The request needs to be set in the "before" methods since what is returned is a deep copy. + */ + request.setParam('cartItemInput', cartItemInput); + } + + // Return the modified request + return request; + } +} \ No newline at end of file diff --git a/commerce/endpoint/cart/CartItemCollectionExtensionSample.cls-meta.xml b/commerce/endpoint/cart/CartItemCollectionExtensionSample.cls-meta.xml new file mode 100644 index 0000000..ba7ea1b --- /dev/null +++ b/commerce/endpoint/cart/CartItemCollectionExtensionSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + \ No newline at end of file diff --git a/commerce/endpoint/cart/CartItemExtensionSample.cls b/commerce/endpoint/cart/CartItemExtensionSample.cls new file mode 100644 index 0000000..dcc953a --- /dev/null +++ b/commerce/endpoint/cart/CartItemExtensionSample.cls @@ -0,0 +1,76 @@ +/** + * Sample extension for updating the listPrice of a cart item based on the currencyIsoCode during edit operations. + * This corresponds to an endpoint /commerce/webstores/${webstoreId}/carts/${activeCartOrId}/cart-items/${cartItemId} + * and is identified by the EPN Commerce_Endpoint_Cart_Item for registration/mapping. + */ +public with sharing class CartItemExtensionSample extends ConnectApi.BaseEndpointExtension { + + // Define a constant for the USD currencyIsoCode + private static final String USD_CURRENCY_ISO_CODE = 'USD'; + + // Define a constant for the AED currencyIsoCode + private static final String AED_CURRENCY_ISO_CODE = 'AED'; + + // Define a constant for the adjustment when the currencyIsoCode is USD + private static final Decimal USD_ADJUSTMENT = 10; + + // Define a constant for the adjustment when the currencyIsoCode is AED + private static final Decimal AED_ADJUSTMENT = 5; + + // Define a constant for the adjustment for currencyIsoCodes other than USD and AED + private static final Decimal ADJUSTMENT = 2; + + + /** + * Overrides the afterPatch method to update the listPrice of a cart item based on the currencyIsoCode. + * + * @param response The endpoint extension response containing cart item data. + * @param request The endpoint extension request. + * @return The modified endpoint extension response with updated listPrice. + */ + public override ConnectApi.EndpointExtensionResponse afterPatch(ConnectApi.EndpointExtensionResponse response, ConnectApi.EndpointExtensionRequest request) { + System.debug('Entering the afterPatch method of Commerce_Endpoint_Cart_Item extension'); + + /** + * Retrieve the cart item from the response object + * More details on the response object can be found in the documentation: + * https://developer.salesforce.com/docs/commerce/salesforce-commerce/guide/extensions.html#connectapiendpointextensionresponse + * */ + ConnectApi.CartItem cartItem = (ConnectApi.CartItem)response.getResponseObject(); + + // Check if cartItem is not null + if (cartItem != null) { + // Check if listPrice and currencyIsoCode are not null + if (cartItem.getListPrice() != null && cartItem.getCurrencyIsoCode() != null) { + + // Retrieve the currencyIsoCode from the cart item + String currencyCode = cartItem.getCurrencyIsoCode(); + + // Retrieve the listPrice from the cart item + String listPriceStr = cartItem.getListPrice(); + + try { + // Convert listPrice from String to Decimal + Decimal listPrice = Decimal.valueOf(listPriceStr); + + // Adjust the listPrice based on the currencyIsoCode + if (currencyCode == USD_CURRENCY_ISO_CODE) { + listPrice += USD_ADJUSTMENT; + } else if (currencyCode == AED_CURRENCY_ISO_CODE) { + listPrice += AED_ADJUSTMENT; + } else { + listPrice += ADJUSTMENT; + } + + // Set the adjusted listPrice back to the cart item + cartItem.setListPrice(String.valueOf(listPrice)); + } catch (Exception e) { + throw new InvalidFormatException('Invalid listPrice format: ' + listPriceStr); + } + } + } + + // Return the modified response + return response; + } +} \ No newline at end of file diff --git a/commerce/endpoint/cart/CartItemExtensionSample.cls-meta.xml b/commerce/endpoint/cart/CartItemExtensionSample.cls-meta.xml new file mode 100644 index 0000000..ba7ea1b --- /dev/null +++ b/commerce/endpoint/cart/CartItemExtensionSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + \ No newline at end of file diff --git a/commerce/endpoint/cart/InvalidFormatException.cls b/commerce/endpoint/cart/InvalidFormatException.cls new file mode 100644 index 0000000..fc1acf9 --- /dev/null +++ b/commerce/endpoint/cart/InvalidFormatException.cls @@ -0,0 +1,4 @@ +/** + * Custom exception class for handling invalid format exceptions. + */ +public with sharing class InvalidFormatException extends Exception{} \ No newline at end of file diff --git a/commerce/endpoint/cart/InvalidFormatException.cls-meta.xml b/commerce/endpoint/cart/InvalidFormatException.cls-meta.xml new file mode 100644 index 0000000..ba7ea1b --- /dev/null +++ b/commerce/endpoint/cart/InvalidFormatException.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + \ No newline at end of file diff --git a/commerce/endpoint/search/SearchProductSearchSample.cls b/commerce/endpoint/search/SearchProductSearchSample.cls new file mode 100644 index 0000000..e84ebf0 --- /dev/null +++ b/commerce/endpoint/search/SearchProductSearchSample.cls @@ -0,0 +1,95 @@ +/** + * Sample extension provider for the legacy POST-based product search endpoint. + * This corresponds to the endpoint /commerce/webstores/${webstoreId}/search/product-search (POST) + * and is identified by the EPN Commerce_Endpoint_Search_ProductSearch for registration/mapping. + * Note: This is an older endpoint that does not receive new development. Consider using the GET variant, + * Commerce_Endpoint_Search_Products. + */ +public with sharing class SearchProductSearchSample extends ConnectApi.BaseEndpointExtension { + + // Define a constant for the search term prefix + private static final String SEARCH_TERM_PREFIX = 'beforeGet executed'; + + // Define a constant for the product name prefix + private static final String PRODUCT_NAME_PREFIX = 'afterGet executed'; + + /** + * Overrides the beforePost method to modify the search term before executing the search. + * + * @param request The endpoint extension request containing search parameters. + * @return The modified endpoint extension request with updated search term. + */ + public override ConnectApi.EndpointExtensionRequest beforePost(ConnectApi.EndpointExtensionRequest request) { + System.debug('Entering the beforePost method of Commerce_Endpoint_Search_ProductSearch extension'); + + // Retrieve the search term from the request + ConnectApi.ProductSearchInput productSearchInput = (ConnectApi.ProductSearchInput)request.getParam('productSearchInput'); + String searchTerm = (String)productSearchInput.searchTerm; + System.debug('Original searchTerm: ' + searchTerm); + + // Modify the search term to demonstrate the extension is executing + String updatedSearchTerm = SEARCH_TERM_PREFIX + searchTerm; + productSearchInput.searchTerm = updatedSearchTerm; + request.setParam('productSearchInput', productSearchInput); + + System.debug('Modified searchTerm to: ' + updatedSearchTerm); + + return request; + } + + /** + * Overrides the afterPost method to modify product names in the search results. + * + * @param response The endpoint extension response containing search results. + * @param request The endpoint extension request. + * @return The modified endpoint extension response with updated product names. + */ + public override ConnectApi.EndpointExtensionResponse afterPost( + ConnectApi.EndpointExtensionResponse response, + ConnectApi.EndpointExtensionRequest request) { + System.debug('Entering the afterPost method of Commerce_Endpoint_Search_ProductSearch extension'); + + // Retrieve the results from the response + ConnectApi.ProductSearchResults searchResults = + (ConnectApi.ProductSearchResults)response.getResponseObject(); + + // Check if searchResults is not null + if (searchResults == null) { + System.debug('No search results found'); + return response; + } + + ConnectApi.ProductSummaryPage productsPage = searchResults.productsPage; + + // Check if products page and products list are not null and not empty + if (productsPage == null || productsPage.products == null || productsPage.products.isEmpty()) { + System.debug('No products to modify'); + return response; + } + + List products = productsPage.products; + System.debug('Processing ' + products.size() + ' products'); + + List updatedProducts = new List(); + + // Reverse the order of products and modify product names to demonstrate the extension is executing + for (Integer i = products.size() - 1; i >= 0; i--) { + ConnectApi.ProductSummary product = products[i]; + + // Modify the Name field to add a prefix + if (product.fields != null && product.fields.containsKey('Name')) { + String originalName = (String)product.fields.get('Name').value; + String newName = PRODUCT_NAME_PREFIX + ' ' + originalName; + product.fields.get('Name').value = newName; + System.debug('Modified product name: ' + originalName + ' -> ' + newName); + } + + updatedProducts.add(product); + } + + // Update the products list with the modified list + searchResults.productsPage.products = updatedProducts; + + return response; + } +} \ No newline at end of file diff --git a/commerce/endpoint/search/SearchProductSearchSample.cls-meta.xml b/commerce/endpoint/search/SearchProductSearchSample.cls-meta.xml new file mode 100644 index 0000000..53b88f8 --- /dev/null +++ b/commerce/endpoint/search/SearchProductSearchSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + Active + \ No newline at end of file diff --git a/commerce/endpoint/search/SearchProductsSample.cls b/commerce/endpoint/search/SearchProductsSample.cls new file mode 100644 index 0000000..d6d2087 --- /dev/null +++ b/commerce/endpoint/search/SearchProductsSample.cls @@ -0,0 +1,89 @@ +/** + * Sample extension provider for modifying product search requests and results. + * This corresponds to the endpoint /commerce/webstores/${webstoreId}/search/products + * and is identified by the EPN Commerce_Endpoint_Search_Products for registration/mapping. + */ +public with sharing class SearchProductsSample extends ConnectApi.BaseEndpointExtension { + + // Define a constant for the search term prefix + private static final String SEARCH_TERM_PREFIX = 'beforeGet executed'; + + // Define a constant for the product name prefix + private static final String PRODUCT_NAME_PREFIX = 'afterGet executed'; + + /** + * Overrides the beforeGet method to transform the search term before executing the search. + * + * @param request The endpoint extension request containing search parameters. + * @return The modified endpoint extension request with transformed search term. + */ + public override ConnectApi.EndpointExtensionRequest beforeGet(ConnectApi.EndpointExtensionRequest request) { + System.debug('Entering the beforeGet method of Commerce_Endpoint_Search_Products extension'); + + // Retrieve the search term from the request + String searchTerm = (String)request.getParam('searchTerm'); + System.debug('Original searchTerm: ' + searchTerm); + + // Modify the search term to demonstrate the extension is executing + String updatedSearchTerm = SEARCH_TERM_PREFIX + searchTerm; + request.setParam('searchTerm', updatedSearchTerm); + + System.debug('Modified searchTerm to: ' + updatedSearchTerm); + + return request; + } + + /** + * Overrides the afterGet method to modify product names and reverse the order of search results. + * + * @param response The endpoint extension response containing search results. + * @param request The endpoint extension request. + * @return The modified endpoint extension response with updated products. + */ + public override ConnectApi.EndpointExtensionResponse afterGet(ConnectApi.EndpointExtensionResponse response, ConnectApi.EndpointExtensionRequest request) { + System.debug('Entering the afterGet method of Commerce_Endpoint_Search_Products extension'); + + // Retrieve the results from the response + ConnectApi.CommerceProductSearchResults searchResults = + (ConnectApi.CommerceProductSearchResults)response.getResponseObject(); + + // Check if searchResults is not null + if (searchResults == null) { + System.debug('No search results found'); + return response; + } + + ConnectApi.CommerceProductSummaryPage productsPage = searchResults.productsPage; + + // Check if products page and products list are not null and not empty + if (productsPage == null || productsPage.products == null || productsPage.products.isEmpty()) { + System.debug('No products to modify'); + return response; + } + + List products = productsPage.products; + System.debug('Processing ' + products.size() + ' products (reversing order and modifying names)'); + + List updatedProducts = new List(); + + // Reverse the order of products and modify product names to demonstrate the extension is executing + for (Integer i = products.size() - 1; i >= 0; i--) { + ConnectApi.CommerceProductSummary product = products[i]; + + // Modify the Name field to add a prefix + if (product.fields != null && product.fields.containsKey('Name')) { + String originalName = (String)product.fields.get('Name').value; + String newName = PRODUCT_NAME_PREFIX + ' ' + originalName; + product.fields.get('Name').value = newName; + System.debug('Modified product name: ' + originalName + ' -> ' + newName); + } + + updatedProducts.add(product); + } + + // Update the products list with the modified and reversed list + searchResults.productsPage.products = updatedProducts; + + return response; + } +} \ No newline at end of file diff --git a/commerce/endpoint/search/SearchProductsSample.cls-meta.xml b/commerce/endpoint/search/SearchProductsSample.cls-meta.xml new file mode 100644 index 0000000..53b88f8 --- /dev/null +++ b/commerce/endpoint/search/SearchProductsSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + Active + \ No newline at end of file diff --git a/commerce/endpoint/search/SearchSuggestionsSample.cls b/commerce/endpoint/search/SearchSuggestionsSample.cls new file mode 100644 index 0000000..822f0bc --- /dev/null +++ b/commerce/endpoint/search/SearchSuggestionsSample.cls @@ -0,0 +1,95 @@ +/** + * Sample extension provider for modifying search suggestions and their associated products. + * This corresponds to the endpoint /commerce/webstores/${webstoreId}/search/suggestions + * and is identified by the EPN Commerce_Endpoint_Search_Suggestions for registration/mapping. + */ +public with sharing class SearchSuggestionsSample extends ConnectApi.BaseEndpointExtension { + + // Define a constant for the search term prefix + private static final String SEARCH_TERM_PREFIX = 'beforeGet executed'; + + // Define a constant for the product name prefix + private static final String PRODUCT_NAME_PREFIX = 'afterGet executed'; + + /** + * Overrides the beforeGet method to modify the search term and enable suggested products. + * + * @param request The endpoint extension request containing search parameters. + * @return The modified endpoint extension request with updated search term. + */ + public override ConnectApi.EndpointExtensionRequest beforeGet(ConnectApi.EndpointExtensionRequest request) { + System.debug('Entering the beforeGet method of Commerce_Endpoint_Search_Suggestions extension'); + + // Retrieve the search term from the request + String searchTerm = (String)request.getParam('searchTerm'); + System.debug('Original searchTerm: ' + searchTerm); + + // Modify the search term to demonstrate the extension is executing + String updatedSearchTerm = SEARCH_TERM_PREFIX + searchTerm; + request.setParam('searchTerm', updatedSearchTerm); + + System.debug('Modified searchTerm to: ' + updatedSearchTerm); + + // Modify another param for demonstration; enable suggested products + request.setParam('includeSuggestedProducts', true); + + return request; + } + + /** + * Overrides the afterGet method to modify product names in search suggestions. + * + * @param response The endpoint extension response containing search suggestions. + * @param request The endpoint extension request. + * @return The modified endpoint extension response with updated product names. + */ + public override ConnectApi.EndpointExtensionResponse afterGet(ConnectApi.EndpointExtensionResponse response, ConnectApi.EndpointExtensionRequest request) { + System.debug('Entering the afterGet method of Commerce_Endpoint_Search_Suggestions extension'); + + // Retrieve the results from the response + ConnectApi.ProductSearchSuggestionsResults suggestionsResults = + (ConnectApi.ProductSearchSuggestionsResults)response.getResponseObject(); + + if (suggestionsResults == null) { + System.debug('No suggestions results found'); + return response; + } + + List suggestions = suggestionsResults.recentSearchSuggestions; + + if (suggestions == null || suggestions.isEmpty()) { + System.debug('No suggestions to modify'); + return response; + } + + System.debug('Processing ' + suggestions.size() + ' suggestions'); + + // Iterate through suggestions and modify product names for those with suggested products. + for (Integer i = 0; i < suggestions.size(); i++) { + ConnectApi.AbstractSearchSuggestion suggestion = suggestions[i]; + + if (suggestion instanceof ConnectApi.SearchSuggestionWithProductSuggestion) { + ConnectApi.SearchSuggestionWithProductSuggestion productSuggestion = + (ConnectApi.SearchSuggestionWithProductSuggestion)suggestion; + + if (productSuggestion.suggestedProducts != null && !productSuggestion.suggestedProducts.isEmpty()) { + + // Update each product name in this suggestion + for (Integer j = 0; j < productSuggestion.suggestedProducts.size(); j++) { + ConnectApi.ProductSummary product = productSuggestion.suggestedProducts[j]; + + // Access the product fields map and modify the Name field + if (product.fields != null && product.fields.containsKey('Name')) { + String originalName = (String)product.fields.get('Name').value; + String newName = PRODUCT_NAME_PREFIX + ' ' + originalName; + product.fields.get('Name').value = newName; + System.debug('Modified product name: ' + originalName + ' -> ' + newName); + } + } + } + } + } + + return response; + } +} \ No newline at end of file diff --git a/commerce/endpoint/search/SearchSuggestionsSample.cls-meta.xml b/commerce/endpoint/search/SearchSuggestionsSample.cls-meta.xml new file mode 100644 index 0000000..0dff683 --- /dev/null +++ b/commerce/endpoint/search/SearchSuggestionsSample.cls-meta.xml @@ -0,0 +1,5 @@ + + + 67.0 + Active + \ No newline at end of file