Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: add ADR on supporting learner credit with EMET #686

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
159 changes: 159 additions & 0 deletions docs/decisions/0008-migrating-to-new-learner-credit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# 0008. Migrating to new learner credit system from ecommerce

## Status

Accepted (04-07-2023)

## Context

The Enterprise Learner Portal currently supports learner credit (aka enterprise offers) via the ecommerce IDA. However, the ecommerce IDA is deprecated and will be replaced by a new system for managing learner credit and other enterprise subsidies. As such, this micro-frontend (MFE) will need to support the migration from the ecommerce-backed learner credit to the new learner credit system in an incremental fashion, given there are production enterprise customers relying on the current implementation of the ecommerce-backed learner credit.

### Understanding support for existing learner credit
There are 3 primary page routes that rely on learner credit throughout the MFE application:
* Dashboard
* Search
* Course

Given that the availability of learner credit is applicable to multiple page routes, the fetching of the learner credit data from the ecommerce API is done within the [`UserSubsidy`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/enterprise-user-subsidy/UserSubsidy.jsx) component, which acts as a context provider to expose subsidy-related data to its descendant components without requiring prop drilling.

It is responsible for fetching the following subsidy-related data via API in parallel:
* Subscription licenses for the authenticated user (`useSubscriptionLicense`)
* Coupon codes assigned to the authenticated user (`useCouponCodes`)
* Learner credit (via ecommerce) available to the authenticated user (`useEnterpriseOffers`).

The `UserSubsidyContext` thus exposes the following data pertinent to learner credit to its descendant components:
* `enterpriseOffers`. List of objects containing metadata about available learner credit stored in the ecommerce IDA. Each object contains the following attributes:
* `offerType` (determined by [`getOfferType`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/enterprise-user-subsidy/enterprise-offers/data/utils.js#L13)).
* `maxDiscount`. Maximum total spend allowed.
* `maxGlobalApplications`. Maximum total number of enrollments allowed.
* `remainingBalance`. The total available balance remaining to be spent by all learners.
* `remainingBalanceforUser`. The available balance remaining specifically for the authenticated user.
* `isLowOnBalance`. Determined by [`isOfferLowOnBalance`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/enterprise-user-subsidy/enterprise-offers/data/utils.js#L32).
* `isOutOfBalance`. Determined by [`isOfferOutOfBalance`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/enterprise-user-subsidy/enterprise-offers/data/utils.js#L51).
* `canEnrollWithEnterpriseOffers`. Boolean representing whether there is at least one enterprise learner credit available.
* `hasLowEnterpriseOffersBalance`. Boolean representing whether any of the `enterpriseOffers` are low on balance.
* `hasNoEnterpriseOffersBalance`. Boolean representing whether all `enterpriseOffers` have no balance remaining.

#### Dashboard

This page route relies on the learner credit data in order to display messaging to learners informing them of having learner credit available to spend. The UI component rendered in the dashboard page's sidebar is the [`EnterpriseOffersSummaryCard`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/dashboard/sidebar/EnterpriseOffersSummaryCard.jsx).

This component gets rendered within the [`SubsidiesSummary`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/dashboard/sidebar/SubsidiesSummary.jsx#L10) component, which renders similar UI components for other subsidies (e.g., subscription licenses).

The `SubsidiesSummary` component gets its data about available learner credit from the `UserSubsidyContext`.

`EnterpriseOffersSummaryCard` either displays a generic message for max global spend or a user-specific message for max user spend remaining. It does not currently support any enrollment limits (e.g., `maxGlobalApplications`). In the case of max user spend remaining, we sum all remaining user balance for all available learner credit. The expiration date shown is from the learner credit that expires first.

#### Search
This page route similarly relies on the data provided by the `UserSubsidyContext` in order to display an alert to inform learners of low/no balance remaining. The rationale for this alert in the UX is to help proactively make learners are aware they may not have enough funds available to cover the cost of all content returned in the search results.
Copy link
Contributor

@pwnage101 pwnage101 Apr 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This context about alerting on low or no balance remaining seems uncovered by the decisions section. What should we do when new learner credit subsidies have low or no balance? How should "low" balance be defined, since we're returning raw balance amounts instead of booleans now? In the name of removing business logic from the frontend, should we change the policy API to return booleans that indicate low balance?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great questions! The context about the alerting for low/no balance remaining was written before I realized we don't actually need to focus on it for the initial EMET release given we're only tackling use cases where the learners are coming from an external LMS. Given that, the dashboard/search pages are not accessible to learners coming from an LMS so the implications for the dashboard/search page are not being accounted for in the initial EMET release. That's probably why the "Decisions" section doesn't really cover it.

That said, you bring up a great point around whether the API should be making the determination of "low" or "no" balance remaining. The existing business logic in the frontend for this are capturing in the following functions:

I'd be in favor of eventually getting this in to the API layer to further remove business logic from the frontend, but I don't think we necessarily need to take action on it until EMET has a requirement to support non-LMS use cases, too.

Copy link
Contributor

@pwnage101 pwnage101 Apr 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! My only suggestion then would be to add a placeholder section/paragraph in decisions to explicitly defer decisions about low balance UI

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Will update!


The alert messaging here does not currently account for enrollment limits.

This alert is implemented via the [`EnterpriseOffersBalanceAlert`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/enterprise-user-subsidy/enterprise-offers/EnterpriseOffersBalanceAlert.jsx) component. It handles both cases of low vs. no balance remaining. The component is rendered within the [`Search`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/search/Search.jsx) component.

`EnterpriseOffersBalanceAlert` is conditionally rendered based on `canEnrollWithEnterpriseOffers` and whether the is low or no balance remaining on the available `enterpriseOffers` from `UserSubsidyContext`.

#### Course
One of the primary responsibilities of the course page route today is to make a determination of which subsidies available to the learner are applicable to the course (e.g., catalog inclusion) and to prioritize certain subsidies over others in the case a learner has multiple subsidies available for the course.

Much of the API data fetching required for the course page route is encapsulated within the [`useAllCourseData`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/course/data/hooks.jsx#L40) React hook used within the [`CoursePage`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/course/CoursePage.jsx) component.

We pass the data for each subsidy into `useAllCourseData` (including `enterpriseOffers` and `canEnrollWithEnterpriseOffers` from `UserSubsidyContext`) and the enterprise catalog UUIDs derived from the subsidies into `useAllCourseData`.

Within `useAllCourseData`, we make API requests to various services to fetch the necessary data for the page route. These requests include:

* Fetch course details metadata from course-discovery, including determining the active course run.
* Fetch the authenticated user's current enrollments for the active enterprise customer.
* Fetch entitlements that user may have available (e.g., in the case of a program purchase).
* Fetch whether the course key is included in the catalogs available to the enterprise customer. This API also returns a list of catalog UUIDs for the enterprise customer which include the course.

Once all these API requests are resolved, we proceed with business logic to do the following:

* Check if the course key is included in the enterprise customer's catalog(s).
* Validate which subsidies are applicable to the course.
* If the user has a subscription license, make an API call to license-manager to check whether the user's subscription license may be applied to the course (based on the catalog associated with the subscription plan tied to the user's license).
* Filters `couponCodes` applicable to the course.
* Filters `enterpriseOffers` applicable to the course (only if `canEnrollWithEnterpriseOffers` is truthy).
* Make a choice of which subsidy to prefer via [`getSubsidyToApplyForCourse`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/course/data/utils.jsx#L255). Prioritizes subsidies in the following order:
* Subscription license
* Coupon code
* Learner credit
* Return the course metadata including the chosen subsidy from `userSubsidyApplicableToCourse` and expose it via [`CourseContextProvider`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/course/CourseContextProvider.jsx) in `CoursePage` to make it available to descendant components without prop drilling.

The `userSubsidyApplicableToCourse` attribute is used for several purposes:
* Determining the course price via [`useCoursePriceForUserSubsidy`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/course/data/hooks.jsx#L265).
* Determining the enrollment type (which dictates the messaging and behavior of the "Enroll" button) via [`determineEnrollmentType`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/course/enrollment/utils.js#L16).
* Generating the enrollment URL for the course (e.g., enrolling with a subscription license goes through Data Sharing Consent while enrolling via learner credit goes through ecommerce's basket page) via [`useCourseEnrollmentUrl`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/course/data/hooks.jsx#L342).

## Decision

In order to support all page routes with the new learner credit system, we will need to understand the state of subsidy spend available to the authenticated user. We will also need to migrate away from much of the business logic housed within `CoursePage` in favor of the `can_redeem` and `redeem` API abstractions within the new learner credit system (via enterprise-access and enterprise-subsidy).

### Implications for `UserSubsidy`

Given that all aforementioned page routes rely on the learner credit metadata exposed by the `UserSubsidyContext`, we will need a way to support data either pulled from the new learner credit system or the legacy ecommerce system. We intend to rely on the interface already exposed by `UserSubsidyContext` to minimize the impacts and changes needed for downstream components.

The metadata pertinent to learner credit that gets exposed by `UserSubsidyContext` is fetched via the [`useEnterpriseOffers`](https://github.com/openedx/frontend-app-learner-portal-enterprise/blob/5034f5ab170589a923c223cfe238112eff48f5c4/src/components/enterprise-user-subsidy/enterprise-offers/data/hooks.js#L11) React hook.

It currently relies on the system-wide feature flag `FEATURE_ENROLL_WITH_ENTERPRISE_OFFERS` and the enterprise customer configuration flag `enableLearnerPortalOffers` to both be truthy. To support the new learner credit system, we will not be relying `enableLearnerPortalOffers`. This boolean flag on the customer configuration is currently used to detemrine whether we should attempt to make API calls to retrieve any learner credit data (such that we can avoid making API calls if a customer doesn't rely on learner credit). The recommendation for the new learner credit system is to no longer rely on `enableLearnerPortalOffers` and always make the API calls even if the customer doesn't utilize learner credit. The performance impacts should largely be mitigated by the fact that we make API calls to fetch all subsidies in parallel rather than waterfall.
Copy link
Contributor

@pwnage101 pwnage101 Apr 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[curious] To be clear, this doesn't mean we're removing the enableLearnerPortalOffers flag, right? Will it still be used to switch old offers on and off?

If not (i.e. we will remove the per-enterprise enableLearnerPortalOffers flag), does that represent an unmitigated performance impact in the case where it was previously False, but also the enterprise has no redeemable subsidy in new learner credit?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[curious] To be clear, this doesn't mean we're removing the enableLearnerPortalOffers flag, right? Will it still be used to switch old offers on and off?

Correct, no current plans to remove the enableLearnerPortalOffers quite yet (and probably not until we no longer need to support legacy enterprise offers). It may still be used to switch old offers on/off for the time being.

I believe I was trying to communicate here that we won't be relying on a customer config flag to determine whether or not to call the can-redeem API endpoint; we will always call it even if that customer has no EMET-compatible subsidies.


Within `useEnterpriseOffers`, we will make API calls to fetch learner credit data from both the legacy ecommerce system as well as the new learner credit system in parallel (e.g., 2 custom React hooks called simulateously or perhaps via `Promise.all`). If there is learner credit returned by the new system, we will use that as the source data for the returned interface by `UserSubsidyContext`. If there is no learner credit data returned by the new system, we will fallback to using any learner credit returned by ecommerce instead.

These changes will require a `GET` API endpoint in enterprise-access to return the policies (and subsequent subsidies) associated with learner credit that are applicable to the authenticated user (e.g., has total spend balance remaining, has user-specific spend balance remaining). Given how these data are used through the UX of this MFE, the API can not be specific to a particular content item; rather, the API response should largely be answering the question of what subsidies are generally available to the learner irrespective of content and/or catalog.

### Implications for course page route

The course page in the Learner Portal currently supports:
* Enterprise offers
* Coupon codes
* Subscription license

As previously described, the course page route currently includes a fair amount of business logic to determine which subsidies available to the learner, if any, are applicable to the course in question.

A significant change with the new EMET system is that much of this business logic will now be abstracted into the API layer instead of within the MFE itself. This paradigm shift will (eventually) allow us to simplify the existing implementation by removing much of this business logic in the current implementation. However, much of the existing business logic needs to remain while the course page continues to support coupon codes and subscription licenses, which do not rely on the EMET system. We do intend to make the EMET system compatible with subscriptions in the future such that calling `can_redeem` would return a policy that is aware of subscription licenses.

Through the first release of EMET, we are converting enterprise offers into EMET learner credit. We plan to migrate eligible enterprise offers customers over to use the EMET learner credit instead. However, this means the course page still needs to support coupon codes and subscription licenses. As such, the majority of the existing API calls and business logic already in place within the course page must remain until codes are phased out and subscription subsidies are supported by the EMET system.

#### `CourseHeader` component

The `CourseHeader` component is responsible for the display of the course title, image, related skills, and renders an "Enroll" CTA for each available course run. A course run is deemed "available" if course-discovery denotes the course run is `is_marketable: true`, `is_enrollable: true`, and is not archived.

The existing `CourseRunCards` component is responsible for iterating through the available course runs and rendering a `CourseRunCard` component with the appropriate messaging and "Enroll" CTA (or "View course" CTA is learner is already enrolled) for each course run.

The existing `CourseRunCard` (rendered by `CourseRunCards`) is responsible for figuring out the display text of the "Enroll" / "View course" CTA and renders the `EnrollAction` component. `EnrollAction` is what determines the *functionality* of the CTA depending on the type of subsidy being used to enroll (i.e., link to Data Sharing Consent, link to ecommerce basket page, disabled "Enroll" button, etc.).

Having the "Enroll" CTA logic essentially split between 2 components (i.e., `CourseRunCard` and `EnrollAction`), it's increasingly difficult to reason about.

To mitigate this concern, we will deprecate the existing `CourseRunCards` component in favor of creating a more streamlined, lightweight `CourseRunCards` component instead. That is, we will have `CourseRunCards` that is integrated with the EMET APIs and fallback to `CourseRunCards.Deprecated` to remain backwards compatible.

#### Triggering EMET redemption for a course run
When a learner clicks on the "Enroll" CTA for a course run that is redeemable by the EMET system (i.e., has learner credit enabled with balance remaining), the general logic is as follows:
* Make a `POST` request to the `redeem` endpoint for the redeemable access policy returned by `can_redeem`. This returns the transaction payload from `enterprise-subsidy`.
* Poll against `enterprise-subsidy` API for the status of the returned transaction UUID until the transaction is in a non-pending state (e.g., "committed").
* If in a "committed" state, redirect learner to courseware URL for OCM courses to begin learning.
* If in an "error" state, we will ensure appropriate messaging is displayed and an option to retry.
Comment on lines +132 to +134
Copy link
Contributor

@pwnage101 pwnage101 Apr 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the create transaction endpoint (POST /api/v1/transactions/) is synchronous.

https://github.com/openedx/enterprise-subsidy/blob/54bee5fb78367f2df72cd0fc809ae621dec88b36/enterprise_subsidy/apps/api/v1/views/transaction.py#L406-L407

Returns 201 (and state will be "committed"), or some other state (in which case the transaction may not get created, or it ended up in "failed"). Either way, after the response is sent, the transaction is in a terminal state if it exists.

Should we be changing this enterprise-subsidy endpoint to be async, or change this ADR to assume that it is synchronous?

Copy link
Contributor

@pwnage101 pwnage101 Apr 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess what makes the API call synchronous or asynchronous is really just whether the returned transaction is "committed"/"failed" or "pending", which doesn't really change the fundamental shape of the endpoint. Request args and status codes would not change when switching from synchronous to asynchronous mode. In other words, the frontend logic could be:

  • Make a POST request to the redeem endpoint for the redeemable access policy returned by can_redeem. This returns the transaction payload from enterprise-subsidy.
  • If the transaction has state "committed":
    • redirect learner to courseware URL for OCM courses to begin learning.
  • If the transaction has state "failed":
    • ensure appropriate messaging is displayed and an option to retry.
  • If the transaction has state "pending":
    • Poll against enterprise-subsidy API for the status of the returned transaction UUID until the transaction is in a non-pending state (e.g., "committed").
    • If in a "committed" state, redirect learner to courseware URL for OCM courses to begin learning.
    • If in an "failed" state, we will ensure appropriate messaging is displayed and an option to retry.

This frontend flexibility will allow us to defer architecture changes to this create endpoint until a later milestone.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you're describing is exactly the intent of this ADR and this PR.

While the first EMET release is synchronous as you called out (will always return state: committed), we're opting to future-proof the frontend to account for when the transaction state may not be synchronous such that when state: 'pending' or state: 'failed' is returned, the frontend should handle it appropriately already.


#### Backwards compatibility with non-EMET subsidy types

The proposed logic (subject to change at implementation) for the course page to attempt to redeem with the EMET APIs but remain backwards compatible with subscription licenses, legacy enterprise offers, and coupon codes is as follows:

1. Learner lands on course page.
1. If `FEATURE_ENABLE_EMET_REDEMPTION` is enabled:
* Fetch `can_redeem` API from `enterprise-access`. This tells us whether the learner can attempt to redeem the course based on the state of subsidies available to the enterprise/learner in `enterprise-subsidy` / `enterprise-access`.
* If `can_redeem` returns a redeemable access policy, we will use the new/simpler `CourseRunCards` component (and a EMET-integrated `StatefulEnroll` component via the new `CourseRunCard`).
* If `can_redeem` does not return a redeemable access policy for the learner/course, stick to using `CourseRunCards.Deprecated` to fallback to current state (which supports subscriptions, coupon codes, etc.).
1. If `FEATURE_ENABLE_EMET_REDEMPTION` is NOT enabled:
* Render `CourseRunCards.Deprecated` to stick to current state.

By implementing this "smart" fallback logic, we will ensure that the course page remains backwards compatible with non-EMET subsidy types (e.g., subscription licenses).

In order to support existing enrollments that were redeemed outside of the EMET system (e.g., subscription license), the new `CourseRunCard` component (via `CourseRunCards`) will continue to rely on parsing the learner's existing `EnterpriseCourseEnrollments`, data available and used by the course page today, to understand whether the learner has an existing enterprise enrollment that was subsidized outside of the EMET system.

## Consequences
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One other consequence related to the point made in the rejected alternatives:

  • This results in enterprise business logic embedded in frontend code for as long as it takes for us to migrate or age-off old subsidies.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout, will add!


* Given the introduction of an extra API call to fetch learner credit from the new system, the MFE will have an extra network request to resolve before we can render the page route and have it be usable by learners.
* Related, part of the recommendation in this ADR is to eliminate the need for the `enableLearnerPortalOffers` configuration flag on the enterprise customer, even for learner credit backed by the deprecated ecommerce IDA. Similar to the above consequence, the implications for these changes would mean that even enterprise customers that don't have any learner credit configured (in ecommerce or the new learner credit system) would be making the API requests to fetch the current state of learner credit.

## Alternatives Considered

* We considered refactoring the existing components related to the display of the "Enroll" CTA per course run. However, the existing components are tighly coupled to data that's not needed in a world with EMET. As such, trying to rework the existing "Enroll" CTA integrated with the EMET APIs would be messy. Instead, we are opting for the approach of keeping the existing components as is, but deprecate them to be eventually removed in the future; this would be in favor of a net-new components that are similar but simpler and easier to reason about.