diff --git a/src/components/learner-credit-management/BudgetDetailRedemptions.jsx b/src/components/learner-credit-management/BudgetDetailRedemptions.jsx index 67fd14f087..0ea2fb766a 100644 --- a/src/components/learner-credit-management/BudgetDetailRedemptions.jsx +++ b/src/components/learner-credit-management/BudgetDetailRedemptions.jsx @@ -5,14 +5,18 @@ import { connect } from 'react-redux'; import LearnerCreditAllocationTable from './LearnerCreditAllocationTable'; import { useBudgetId, useOfferRedemptions } from './data'; -const BudgetDetailRedemptions = ({ enterpriseUUID }) => { +const BudgetDetailRedemptions = ({ enterpriseFeatures, enterpriseUUID }) => { const { enterpriseOfferId, subsidyAccessPolicyId } = useBudgetId(); const { isLoading, offerRedemptions, fetchOfferRedemptions, - } = useOfferRedemptions(enterpriseUUID, enterpriseOfferId, subsidyAccessPolicyId); - + } = useOfferRedemptions( + enterpriseUUID, + enterpriseOfferId, + subsidyAccessPolicyId, + enterpriseFeatures.topDownAssignmentRealTimeLcm, + ); return (

Spent

@@ -30,11 +34,15 @@ const BudgetDetailRedemptions = ({ enterpriseUUID }) => { }; const mapStateToProps = state => ({ + enterpriseFeatures: state.portalConfiguration.enterpriseFeatures, enterpriseUUID: state.portalConfiguration.enterpriseId, }); BudgetDetailRedemptions.propTypes = { enterpriseUUID: PropTypes.string.isRequired, + enterpriseFeatures: PropTypes.shape({ + topDownAssignmentRealTimeLcm: PropTypes.bool, + }).isRequired, }; export default connect(mapStateToProps)(BudgetDetailRedemptions); diff --git a/src/components/learner-credit-management/data/hooks/useOfferRedemptions.js b/src/components/learner-credit-management/data/hooks/useOfferRedemptions.js index 356c05404d..c5f824b8a1 100644 --- a/src/components/learner-credit-management/data/hooks/useOfferRedemptions.js +++ b/src/components/learner-credit-management/data/hooks/useOfferRedemptions.js @@ -10,8 +10,10 @@ import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import debounce from 'lodash.debounce'; import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; +import SubsidyApiService from '../../../../data/services/EnterpriseSubsidyApiService'; import { API_FIELDS_BY_TABLE_COLUMN_ACCESSOR } from '../constants'; -import { transformUtilizationTableResults } from '../utils'; +import { transformUtilizationTableResults, transformUtilizationTableSubsidyTransactionResults } from '../utils'; +import useSubsidyAccessPolicy from './useSubsidyAccessPolicy'; const applySortByToOptions = (sortBy, options) => { const orderingStrings = sortBy.map(({ id, desc }) => { @@ -29,19 +31,26 @@ const applySortByToOptions = (sortBy, options) => { }); }; -const applyFiltersToOptions = (filters, options) => { +const applyFiltersToOptions = (filters, options, shouldFetchSubsidyTransactions = false) => { const courseProductLineSearchQuery = filters?.find(filter => filter.id === 'courseProductLine')?.value; - const searchQuery = filters?.find(filter => filter.id.toLowerCase() === 'enrollment details')?.value; + const searchQuery = filters?.find(filter => filter.id === 'enrollmentDetails')?.value; if (courseProductLineSearchQuery) { Object.assign(options, { courseProductLine: courseProductLineSearchQuery }); } if (searchQuery) { - Object.assign(options, { searchAll: searchQuery }); + const searchParams = {}; + searchParams[shouldFetchSubsidyTransactions ? 'search' : 'searchAll'] = searchQuery; + Object.assign(options, searchParams); } }; -const useOfferRedemptions = (enterpriseUUID, offerId = null, budgetId = null) => { +const useOfferRedemptions = ( + enterpriseUUID, + offerId = null, + budgetId = null, + shouldFetchSubsidyTransactions = false, +) => { const shouldTrackFetchEvents = useRef(false); const [isLoading, setIsLoading] = useState(true); const [offerRedemptions, setOfferRedemptions] = useState({ @@ -49,6 +58,7 @@ const useOfferRedemptions = (enterpriseUUID, offerId = null, budgetId = null) => pageCount: 0, results: [], }); + const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(budgetId); const fetchOfferRedemptions = useCallback((args) => { const fetch = async () => { @@ -69,14 +79,26 @@ const useOfferRedemptions = (enterpriseUUID, offerId = null, budgetId = null) => applySortByToOptions(args.sortBy, options); } if (args.filters?.length > 0) { - applyFiltersToOptions(args.filters, options); + applyFiltersToOptions(args.filters, options, shouldFetchSubsidyTransactions); } - const response = await EnterpriseDataApiService.fetchCourseEnrollments( - enterpriseUUID, - options, - ); - const data = camelCaseObject(response.data); - const transformedTableResults = transformUtilizationTableResults(data.results); + let data; + let transformedTableResults; + if (budgetId && shouldFetchSubsidyTransactions) { + const response = await SubsidyApiService.fetchCustomerTransactions( + subsidyAccessPolicy?.subsidyUuid, + options, + ); + data = camelCaseObject(response.data); + transformedTableResults = transformUtilizationTableSubsidyTransactionResults(data.results); + } else { + const response = await EnterpriseDataApiService.fetchCourseEnrollments( + enterpriseUUID, + options, + ); + data = camelCaseObject(response.data); + transformedTableResults = transformUtilizationTableResults(data.results); + } + setOfferRedemptions({ itemCount: data.count, pageCount: data.numPages, @@ -104,7 +126,14 @@ const useOfferRedemptions = (enterpriseUUID, offerId = null, budgetId = null) => if (offerId || budgetId) { fetch(); } - }, [enterpriseUUID, offerId, budgetId, shouldTrackFetchEvents]); + }, [ + enterpriseUUID, + offerId, + budgetId, + shouldTrackFetchEvents, + shouldFetchSubsidyTransactions, + subsidyAccessPolicy?.subsidyUuid, + ]); const debouncedFetchOfferRedemptions = useMemo(() => debounce(fetchOfferRedemptions, 300), [fetchOfferRedemptions]); diff --git a/src/components/learner-credit-management/data/hooks/useOfferRedemptions.test.js b/src/components/learner-credit-management/data/hooks/useOfferRedemptions.test.js deleted file mode 100644 index cfe7affd2d..0000000000 --- a/src/components/learner-credit-management/data/hooks/useOfferRedemptions.test.js +++ /dev/null @@ -1,89 +0,0 @@ -import { act, renderHook } from '@testing-library/react-hooks/dom'; -import { camelCaseObject } from '@edx/frontend-platform/utils'; - -import useOfferRedemptions from './useOfferRedemptions'; -import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; - -const TEST_ENTERPRISE_UUID = 'test-enterprise-uuid'; -const TEST_ENTERPRISE_OFFER_ID = 1; - -const mockOfferEnrollments = [{ - user_email: 'edx@example.com', - course_title: 'Test Course Title', - course_list_price: '100.00', - enrollment_date: '2022-01-01', -}]; - -const mockOfferEnrollmentsResponse = { - count: 100, - current_page: 1, - num_pages: 5, - results: mockOfferEnrollments, -}; - -const mockEnterpriseOffer = { - id: TEST_ENTERPRISE_OFFER_ID, -}; - -jest.mock('../../../../data/services/EnterpriseDataApiService'); - -describe('useOfferRedemptions', () => { - it('should fetch enrollment/redemptions metadata for enterprise offer', async () => { - EnterpriseDataApiService.fetchCourseEnrollments.mockResolvedValueOnce({ data: mockOfferEnrollmentsResponse }); - const budgetId = 'test-budget-id'; - const { result, waitForNextUpdate } = renderHook(() => useOfferRedemptions( - TEST_ENTERPRISE_UUID, - mockEnterpriseOffer.id, - budgetId, - )); - - expect(result.current).toMatchObject({ - offerRedemptions: { - itemCount: 0, - pageCount: 0, - results: [], - }, - isLoading: true, - fetchOfferRedemptions: expect.any(Function), - }); - act(() => { - result.current.fetchOfferRedemptions({ - pageIndex: 0, // `DataTable` uses zero-based indexing - pageSize: 20, - sortBy: [ - { id: 'enrollmentDate', desc: true }, - ], - filters: [ - { id: 'Enrollment Details', value: mockOfferEnrollments[0].user_email }, - ], - }); - }); - - await waitForNextUpdate(); - - const expectedApiOptions = { - page: 1, - pageSize: 20, - offerId: mockEnterpriseOffer.id, - ordering: '-enrollment_date', // default sort order - searchAll: mockOfferEnrollments[0].user_email, - ignoreNullCourseListPrice: true, - budgetId, - }; - expect(EnterpriseDataApiService.fetchCourseEnrollments).toHaveBeenCalledWith( - TEST_ENTERPRISE_UUID, - expectedApiOptions, - ); - expect(result.current).toMatchObject({ - offerRedemptions: { - itemCount: 100, - pageCount: 5, - results: camelCaseObject(mockOfferEnrollments), - }, - isLoading: false, - fetchOfferRedemptions: expect.any(Function), - }); - - expect(expectedApiOptions.budgetId).toBe(budgetId); - }); -}); diff --git a/src/components/learner-credit-management/data/hooks/useOfferRedemptions.test.jsx b/src/components/learner-credit-management/data/hooks/useOfferRedemptions.test.jsx new file mode 100644 index 0000000000..9b5d1c3c9f --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useOfferRedemptions.test.jsx @@ -0,0 +1,165 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react-hooks/dom'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; + +import useOfferRedemptions from './useOfferRedemptions'; +import useSubsidyAccessPolicy from './useSubsidyAccessPolicy'; +import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService'; +import SubsidyApiService from '../../../../data/services/EnterpriseSubsidyApiService'; +import { queryClient } from '../../../test/testUtils'; + +const TEST_ENTERPRISE_UUID = 'test-enterprise-uuid'; +const TEST_ENTERPRISE_OFFER_ID = 1; +const subsidyUuid = 'test-subsidy-uuid'; +const courseTitle = 'Test Course Title'; +const userEmail = 'edx@example.com'; + +const mockOfferEnrollments = [{ + user_email: userEmail, + course_title: courseTitle, + course_list_price: '100.00', + enrollment_date: '2022-01-01', +}]; + +const mockOfferEnrollmentsResponse = { + count: 100, + current_page: 1, + num_pages: 5, + results: mockOfferEnrollments, +}; + +const mockSubsidyTransactionResponse = { + count: 100, + current_page: 1, + num_pages: 5, + results: [{ + uuid: subsidyUuid, + state: 'committed', + idempotency_key: '5d00d319-fe46-41f7-b14e-966534da9f72', + lms_user_id: 999, + lms_user_email: userEmail, + content_key: 'course-v1:edX+test+course.1', + content_title: courseTitle, + quantity: -1000, + unit: 'usd_cents', + }], +}; + +const mockEnterpriseOffer = { + id: TEST_ENTERPRISE_OFFER_ID, +}; + +jest.mock('./useSubsidyAccessPolicy'); +jest.mock('../../../../data/services/EnterpriseDataApiService'); +jest.mock('../../../../data/services/EnterpriseSubsidyApiService'); + +const wrapper = ({ children }) => ( + {children} +); + +describe('useOfferRedemptions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + budgetId: 'test-budget-id', + offerId: undefined, + shouldFetchSubsidyTransactions: true, + }, + { + budgetId: 'test-budget-id', + offerId: undefined, + shouldFetchSubsidyTransactions: false, + }, + { + budgetId: undefined, + offerId: mockEnterpriseOffer.id, + shouldFetchSubsidyTransactions: false, + }, + ])('should fetch enrollment/redemptions metadata for enterprise offer', async ({ + budgetId, + offerId, + shouldFetchSubsidyTransactions, + }) => { + EnterpriseDataApiService.fetchCourseEnrollments.mockResolvedValueOnce({ data: mockOfferEnrollmentsResponse }); + SubsidyApiService.fetchCustomerTransactions.mockResolvedValueOnce({ data: mockSubsidyTransactionResponse }); + useSubsidyAccessPolicy.mockReturnValue({ data: { subsidyUuid } }); + + const { result, waitForNextUpdate } = renderHook( + () => useOfferRedemptions(TEST_ENTERPRISE_UUID, offerId, budgetId, shouldFetchSubsidyTransactions), + { wrapper }, + ); + + expect(result.current).toMatchObject({ + offerRedemptions: { + itemCount: 0, + pageCount: 0, + results: [], + }, + isLoading: true, + fetchOfferRedemptions: expect.any(Function), + }); + act(() => { + result.current.fetchOfferRedemptions({ + pageIndex: 0, // `DataTable` uses zero-based indexing + pageSize: 20, + sortBy: [ + { id: 'enrollmentDate', desc: true }, + ], + filters: [ + { id: 'enrollmentDetails', value: mockOfferEnrollments[0].user_email }, + ], + }); + }); + + await waitForNextUpdate(); + + if (budgetId && shouldFetchSubsidyTransactions) { + const expectedApiOptions = { + page: 1, + pageSize: 20, + offerId, + ordering: '-enrollment_date', // default sort order + search: mockOfferEnrollments[0].user_email, + ignoreNullCourseListPrice: true, + budgetId, + }; + expect(SubsidyApiService.fetchCustomerTransactions).toHaveBeenCalledWith( + subsidyUuid, + expectedApiOptions, + ); + } else { + const expectedApiOptions = { + page: 1, + pageSize: 20, + offerId, + ordering: '-enrollment_date', // default sort order + searchAll: mockOfferEnrollments[0].user_email, + ignoreNullCourseListPrice: true, + budgetId, + }; + expect(EnterpriseDataApiService.fetchCourseEnrollments).toHaveBeenCalledWith( + TEST_ENTERPRISE_UUID, + expectedApiOptions, + ); + } + + const mockExpectedResultsObj = shouldFetchSubsidyTransactions ? [{ + courseListPrice: 10, + courseTitle, + userEmail, + }] : camelCaseObject(mockOfferEnrollments); + + expect(result.current).toMatchObject({ + offerRedemptions: { + itemCount: 100, + pageCount: 5, + results: mockExpectedResultsObj, + }, + isLoading: false, + fetchOfferRedemptions: expect.any(Function), + }); + }); +}); diff --git a/src/components/learner-credit-management/data/tests/constants.js b/src/components/learner-credit-management/data/tests/constants.js index a72d643f63..285a0e7e82 100644 --- a/src/components/learner-credit-management/data/tests/constants.js +++ b/src/components/learner-credit-management/data/tests/constants.js @@ -12,6 +12,7 @@ export const mockAssignableSubsidyAccessPolicy = { spendAvailableUsd: 10000, }, isAssignable: true, + subsidyUuid: 'mock-subsidy-uuid', }; export const mockPerLearnerSpendLimitSubsidyAccessPolicy = { @@ -22,4 +23,5 @@ export const mockPerLearnerSpendLimitSubsidyAccessPolicy = { spendAvailableUsd: 10000, }, isAssignable: false, + subsidyUuid: 'mock-subsidy-uuid', }; diff --git a/src/components/learner-credit-management/data/utils.js b/src/components/learner-credit-management/data/utils.js index 61122ad402..5780e28eb2 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -95,6 +95,16 @@ export const transformUtilizationTableResults = results => results.map(result => courseKey: result.courseKey, })); +export const transformUtilizationTableSubsidyTransactionResults = results => results.map(result => ({ + created: result.created, + enterpriseEnrollmentId: result.fulfillmentIdentifier, + userEmail: result.lmsUserEmail, + courseTitle: result.contentTitle, + courseListPrice: result.unit === 'usd_cents' ? -1 * (result.quantity / 100) : -1 * results.quantity, + uuid: result.uuid, + courseKey: result.contentKey, +})); + /** * Gets appropriate color variant for the annotated progress bar. * diff --git a/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx b/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx index 6f2574d25f..d8729c61b5 100644 --- a/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx +++ b/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx @@ -209,11 +209,11 @@ describe('', () => { it.each([ { budgetId: mockEnterpriseOfferId, - expectedUseOfferRedemptionsArgs: [enterpriseUUID, mockEnterpriseOfferId, null], + expectedUseOfferRedemptionsArgs: [enterpriseUUID, mockEnterpriseOfferId, null, true], }, { budgetId: mockSubsidyAccessPolicyUUID, - expectedUseOfferRedemptionsArgs: [enterpriseUUID, null, mockSubsidyAccessPolicyUUID], + expectedUseOfferRedemptionsArgs: [enterpriseUUID, null, mockSubsidyAccessPolicyUUID, true], }, ])('displays spend table in "Activity" tab with empty results (%s)', async ({ budgetId, diff --git a/src/data/services/EnterpriseSubsidyApiService.js b/src/data/services/EnterpriseSubsidyApiService.js index f1769424bf..84550170fa 100644 --- a/src/data/services/EnterpriseSubsidyApiService.js +++ b/src/data/services/EnterpriseSubsidyApiService.js @@ -4,19 +4,29 @@ import { snakeCaseObject } from '@edx/frontend-platform'; import { configuration } from '../../config'; class SubsidyApiService { - static baseUrl = `${configuration.ENTERPRISE_SUBSIDY_BASE_URL}/api/v1`; + static baseUrl = `${configuration.ENTERPRISE_SUBSIDY_BASE_URL}/api`; + + static baseUrlV1 = `${this.baseUrl}/v1`; + + static baseUrlV2 = `${this.baseUrl}/v2`; static apiClient = getAuthenticatedHttpClient; + static fetchCustomerTransactions(subsidyUuid, options = {}) { + const queryParams = new URLSearchParams({ + ...snakeCaseObject(options), + }); + const url = `${SubsidyApiService.baseUrlV2}/subsidies/${subsidyUuid}/transactions/?${queryParams.toString()}`; + return SubsidyApiService.apiClient().get(url); + } + static getSubsidyByCustomerUUID(uuid, options = {}) { const queryParams = new URLSearchParams({ enterprise_customer_uuid: uuid, ...snakeCaseObject(options), }); - const url = `${SubsidyApiService.baseUrl}/subsidies/?${queryParams.toString()}`; - return SubsidyApiService.apiClient({ - useCache: configuration.USE_API_CACHE, - }).get(url, { clearCacheEntry: true }); + const url = `${SubsidyApiService.baseUrlV1}/subsidies/?${queryParams.toString()}`; + return SubsidyApiService.apiClient().get(url); } } diff --git a/src/data/services/tests/EnterpriseSubsidyApiService.test.js b/src/data/services/tests/EnterpriseSubsidyApiService.test.js index 18797031f6..6c894bc1f8 100644 --- a/src/data/services/tests/EnterpriseSubsidyApiService.test.js +++ b/src/data/services/tests/EnterpriseSubsidyApiService.test.js @@ -15,11 +15,16 @@ describe('EnterpriseSubsidyApiService', () => { beforeEach(() => { jest.clearAllMocks(); }); - + test('fetchCustomerTransactions calls the API to fetch transactions by enterprise subsidy', () => { + const mockSubsidyUUID = 'test-subsidy-uuid'; + const expectedUrl = `${SubsidyApiService.baseUrlV2}/subsidies/${mockSubsidyUUID}/transactions/?`; + SubsidyApiService.fetchCustomerTransactions(mockSubsidyUUID); + expect(axios.get).toBeCalledWith(expectedUrl); + }); test('getSubsidyByCustomerUUID calls the API to fetch subsides by enterprise customer UUID', () => { const mockCustomerUUID = 'test-customer-uuid'; - const expectedUrl = `${SubsidyApiService.baseUrl}/subsidies/?enterprise_customer_uuid=${mockCustomerUUID}`; + const expectedUrl = `${SubsidyApiService.baseUrlV1}/subsidies/?enterprise_customer_uuid=${mockCustomerUUID}`; SubsidyApiService.getSubsidyByCustomerUUID(mockCustomerUUID); - expect(axios.get).toBeCalledWith(expectedUrl, { clearCacheEntry: true }); + expect(axios.get).toBeCalledWith(expectedUrl); }); });