Skip to content

one click upgrade for courses in verified program enrollments - #3671

Open
gumaerc wants to merge 3 commits into
mainfrom
cg/verified-program-enrollment-courserun-upgrade-edge-case
Open

one click upgrade for courses in verified program enrollments#3671
gumaerc wants to merge 3 commits into
mainfrom
cg/verified-program-enrollment-courserun-upgrade-edge-case

Conversation

@gumaerc

@gumaerc gumaerc commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

Closes https://github.com/mitodl/hq/issues/12269

Description (What does it do?)

Learners who audit-enrolled in a course before purchasing a program that includes that course were being sent to checkout when they clicked "Upgrade for certificate" on the program dashboard — even though their program purchase already entitles them to a free verified upgrade for that course.

The mitxonline backend already supported a free, one-click upgrade path for this case (add_verified_program_course_enrollment / useCreateVerifiedProgramEnrollment, the same endpoint the "Start" button uses for unenrolled courses in a verified program). The gap was entirely on the frontend: EnrolledCourseCard's upgrade link was hard-wired to useReplaceBasketItem, and the card never received the ancestor program-enrollment context that UnenrolledCourseCard already had access to.

This PR:

  • Threads entry.ancestorContext (already computed by the program dashboard's DashboardCourseEntry) from CoursewareCard into EnrolledCourseCard, for the "course" kind's enrolled branch.
  • In EnrolledCourseCard, when the enrollment is audit and the ancestor program enrollment is verified, the "Upgrade for certificate" link now calls useCreateVerifiedProgramEnrollment (one-click, free) instead of useReplaceBasketItem (checkout), and redirects to courseware on success — matching the "Start" button's behavior, per the issue's suggested fix.
  • Drops the price suffix from the link label in this case ("Upgrade for certificate" instead of "Upgrade for certificate - $999"), since the upgrade is free.
  • Leaves the existing checkout behavior unchanged for every other case (no program context, or an audit program enrollment).

No backend changes were required — add_verified_program_course_enrollment already upgrades a pre-existing audit course-run enrollment to verified via a zero-value order when the program enrollment is verified.

How can this be tested?

  1. In mitxonline, seed repro data (no factories, real model calls) with localdev/seed_verified_program_upgrade_repro.py:
    docker compose exec -e REPRO_USER_EMAIL=<your test user email> web python manage.py shell < localdev/seed_verified_program_upgrade_repro.py
    
    This creates a live course/run with an active $999 upgrade product, a program requiring that course with its own product, a verified ProgramEnrollment, and an audit CourseRunEnrollment in the run for the given user. Idempotent — safe to re-run. Here is the code for the one shot:
"""
One-shot repro data for: audit course enrollment + verified program enrollment
shows a paid "Upgrade for certificate" link on the program dashboard.

Creates:
- A live course with one live, upgradable run (fake run tag, so enrollment
  API calls skip edX locally)
- An active $999 Product for the run (with a reversion Version, required
  by checkout)
- A live program requiring the course, plus a program Product (required by
  create_verified_program_discount)
- A VERIFIED ProgramEnrollment for the user
- An AUDIT CourseRunEnrollment for the user in the run

Run with:
    docker compose run --rm \
        -e REPRO_USER_EMAIL=admin@odl.local \
        web python manage.py shell < localdev/seed_verified_program_upgrade_repro.py

Idempotent: safe to run more than once.
"""

import os
from datetime import timedelta
from decimal import Decimal

import reversion
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from reversion.models import Version

from courses.models import (
    Course,
    CourseRun,
    CourseRunEnrollment,
    EnrollmentMode,
    Program,
    ProgramEnrollment,
)
from ecommerce.models import Product
from mitol.common.utils.datetime import now_in_utc
from openedx.constants import (
    EDX_ENROLLMENT_AUDIT_MODE,
    EDX_ENROLLMENT_VERIFIED_MODE,
)

USER_EMAIL = os.environ.get("REPRO_USER_EMAIL", "admin@odl.local")

COURSE_READABLE_ID = "course-v1:MITxT+VPE.Repro"
RUN_TAG = "fake-R1"  # "fake" prefix -> is_fake_course_run, skips edX calls
PROGRAM_READABLE_ID = "program-v1:MITxT+VPE.Repro-Program"

User = get_user_model()

try:
    user = User.objects.get(email=USER_EMAIL)
except User.DoesNotExist:
    msg = (
        f"No user with email {USER_EMAIL!r}. Log into mitxonline locally as "
        "that user first (or set REPRO_USER_EMAIL)."
    )
    raise SystemExit(msg)  # noqa: B904

now = now_in_utc()

audit_mode, _ = EnrollmentMode.objects.get_or_create(
    mode_slug=EDX_ENROLLMENT_AUDIT_MODE,
    defaults={"mode_display_name": "Audit"},
)
verified_mode, _ = EnrollmentMode.objects.get_or_create(
    mode_slug=EDX_ENROLLMENT_VERIFIED_MODE,
    defaults={"mode_display_name": "Verified", "requires_payment": True},
)

course, course_created = Course.objects.get_or_create(
    readable_id=COURSE_READABLE_ID,
    defaults={"title": "VPE Repro: Audit-then-Program Course", "live": True},
)

run, run_created = CourseRun.objects.get_or_create(
    courseware_id=f"{COURSE_READABLE_ID}+{RUN_TAG}",
    defaults={
        "course": course,
        "title": course.title,
        "run_tag": RUN_TAG,
        "live": True,
        "start_date": now - timedelta(days=30),
        "end_date": now + timedelta(days=180),
        "enrollment_start": now - timedelta(days=30),
        "enrollment_end": now + timedelta(days=150),
        "upgrade_deadline": now + timedelta(days=60),
        "certificate_available_date": now + timedelta(days=181),
    },
)
run.enrollment_modes.add(audit_mode, verified_mode)


def ensure_product(purchasable, price, description):
    """Get or create an active Product with a reversion Version."""
    content_type = ContentType.objects.get_for_model(purchasable)
    product = Product.objects.filter(
        content_type=content_type, object_id=purchasable.id, is_active=True
    ).first()
    if product is None:
        with reversion.create_revision():
            product = Product.objects.create(
                content_type=content_type,
                object_id=purchasable.id,
                price=price,
                description=description,
                is_active=True,
            )
    elif not Version.objects.get_for_object(product).exists():
        # Checkout resolves prices from the product's Version, not the row.
        with reversion.create_revision():
            product.save()
    return product


run_product = ensure_product(run, Decimal("999.00"), f"Certificate: {run.title}")

program, program_created = Program.objects.get_or_create(
    readable_id=PROGRAM_READABLE_ID,
    defaults={"title": "VPE Repro Program", "live": True},
)
if course not in [*program.required_courses, *program.elective_courses]:
    program.add_requirement(course)

program_product = ensure_product(
    program, Decimal("1999.00"), f"Program: {program.title}"
)

program_enrollment, pe_created = ProgramEnrollment.all_objects.get_or_create(
    user=user,
    program=program,
    defaults={"enrollment_mode": EDX_ENROLLMENT_VERIFIED_MODE, "active": True},
)
if (
    program_enrollment.enrollment_mode != EDX_ENROLLMENT_VERIFIED_MODE
    or not program_enrollment.active
):
    program_enrollment.enrollment_mode = EDX_ENROLLMENT_VERIFIED_MODE
    program_enrollment.active = True
    program_enrollment.save()

run_enrollment, cre_created = CourseRunEnrollment.all_objects.get_or_create(
    user=user,
    run=run,
    defaults={
        "enrollment_mode": EDX_ENROLLMENT_AUDIT_MODE,
        "active": True,
        "edx_enrolled": False,
    },
)
if (
    run_enrollment.enrollment_mode != EDX_ENROLLMENT_AUDIT_MODE
    or not run_enrollment.active
):
    run_enrollment.enrollment_mode = EDX_ENROLLMENT_AUDIT_MODE
    run_enrollment.active = True
    run_enrollment.save()

print(  # noqa: T201
    f"""
Done.
  user:                 {user.email} (id={user.id})
  course:               {course.readable_id} ({"created" if course_created else "existing"})
  run:                  {run.courseware_id} ({"created" if run_created else "existing"}, upgradable={run.is_upgradable})
  run product:          #{run_product.id} ${run_product.price}
  program:              {program.readable_id} (id={program.id}, {"created" if program_created else "existing"})
  program product:      #{program_product.id} ${program_product.price}
  program enrollment:   {program_enrollment.enrollment_mode} ({"created" if pe_created else "existing"})
  course run enrollment: {run_enrollment.enrollment_mode} ({"created" if cre_created else "existing"})

Repro: open the program dashboard in mit-learn:
  /dashboard/program/{program.id}
The audit-enrolled course should show "Upgrade for certificate - $999.00",
which currently sends you to checkout despite the verified program enrollment.

Note: leave SYNC_ON_DASHBOARD_LOAD disabled locally, or the edX sync may
clobber the seeded enrollment.
"""
)
  1. Log into mit-learn as that user and open the printed program dashboard URL (/dashboard/program/<id>).
  2. Before this change: the seeded course card shows "Upgrade for certificate - $999.00" and clicking it sends you to /cart/.
  3. After this change: the same card shows "Upgrade for certificate" (no price) and clicking it one-click-upgrades the enrollment to verified and redirects to the course's courseware URL, with no checkout step.
  4. Confirm the enrollment actually flipped: CourseRunEnrollment.enrollment_mode for that run/user should now be verified.
  5. Regression check: on a course card with no ancestor program context (e.g. home "My Learning"), or where the program enrollment is audit, the upgrade link should still behave exactly as before (checkout with price shown).

Automated coverage: EnrolledCourseCard.test.tsx adds 3 tests for the verified-program path (label omits price, click one-click-enrolls and redirects, error handling), alongside the existing checkout-path tests (unchanged). Full suite (97 tests), typecheck, and lint all pass.

Additional Context

  • Home "My Learning" cards don't carry program ancestorContext, so a course enrolled outside its program dashboard won't get this one-click behavior even if the learner has a verified enrollment in some other program containing that course. Per team discussion, this is expected to be rare in practice (course-run enrollments for courses in an actively-enrolled program are supposed to be hidden from My Learning), so it's out of scope here.
  • Post-click behavior redirects to courseware (same as the "Start" button), rather than staying on the dashboard. Flagging in case design wants to revisit this during review.
  • Possible duplicates called out in the original issue: mitodl/hq#10646, mitodl/hq#12262.

Copilot AI review requested due to automatic review settings July 23, 2026 20:28
@gumaerc gumaerc added the Needs Review An open Pull Request that is ready for review label Jul 23, 2026
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

OpenAPI Changes

No changes detected

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the MIT Learn program dashboard course cards so that when a learner is audit-enrolled in a course run but has a verified program enrollment that includes that course, clicking “Upgrade for certificate” performs the existing one-click verified upgrade flow (no checkout) and then redirects to courseware.

Changes:

  • Thread entry.ancestorContext through CoursewareCard into the enrolled-course card path so enrolled cards have program-enrollment context.
  • Add a verified-program upgrade branch to EnrolledCourseCard’s upgrade link behavior (use useCreateVerifiedProgramEnrollment and omit the price in the label).
  • Add test coverage for the verified-program upgrade label, success redirect, and error handling.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx Adds verified-program one-click upgrade handling and threads ancestor program context into the upgrade banner.
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx Adds tests for the verified-program one-click upgrade behavior and label formatting.
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CoursewareCard.tsx Passes ancestorContext into the enrolled course card for the "course" kind’s enrolled branch.
Comments suppressed due to low confidence (1)

frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx:309

  • onUpgradeError always reports "adding the certificate to your cart", but in the verified-program path this action is a free one-click enrollment upgrade (no cart/checkout). Updating the message makes the error actionable and avoids confusing users when the verified-program upgrade API fails.
      onError={() => {
        onUpgradeError?.(
          "There was a problem adding the certificate to your cart.",
        )
      }}

@ChristopherChudzicki ChristopherChudzicki self-assigned this Jul 24, 2026
@gumaerc
gumaerc force-pushed the cg/verified-program-enrollment-courserun-upgrade-edge-case branch from bf4ed49 to 8b2e90a Compare July 24, 2026 16:30

@ChristopherChudzicki ChristopherChudzicki left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is working pretty well, code generally looks good 👍 I did notice two issues:

  1. An enrollment issue
  2. UI doesn't reflect enrollment error

Issue one

Assume that products + audit & verified enrollment modes exist for all courses/programs/runs below.

Overall Program (e.g., SDS)
    Child Program 1
        Course x
        Course y
        Course z1
    Child Program 2
        Course x
        Course y
        Course z2

Note: Above, Courses x & y are the same, i.e., same course in both CP1 and CP2. That's how SDS sets up its "tracks". Not important for this bug, though.

Then:

Assume a user has
- Audit Enrollment Overall Program
- Verified enrollment in Child Program 1
- Audit enrollment in x

Then clicking "Upgrade" on Course x within Child Program 1 is an enrollment error. Frontend does POST https://api.learn.mit.dev/mitxonline/api/v2/verified_program_enrollments/COURSERUN_READABLE_ID with body [program-v1:child-program-1, program-v1:overall-program], and backend says "User has no verified enrollment in program-v1:overall-program".

Image

SUGGESTION:

The point of posting to verified_program_enrollments/{RUN_READABLE_ID} with body [p1,p2] is the user has a verified enrollment in p2 but needs verified enrollment in p1.

Can we omit p2 if their p2 enrollment is not verified?

We only have two progarms here, but if we had more, ordered as [p1, p2 parent of p1, p3 parent of p2, p4 parent of p3, ...], we'd just trim any unverified enrollments from END of the array (but not beginning).

Maybe there's a better solution ... ☝️ made sense to me, though.

Issue 2

In the above scenario, the UI does not reflect the error at all.

Could we show an error text or something? Even an alert within the card... might look weird but would be very visible.

@gumaerc

gumaerc commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@ChristopherChudzicki Thanks, this is ready for another look. I implemented the suggestion for fixing the bug and an error is now displayed:

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Review An open Pull Request that is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants