Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion frontends/api/src/mitxonline/hooks/orders/queries.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { queryOptions } from "@tanstack/react-query"
import { ordersApi } from "../../clients"
import type { Order } from "@mitodl/mitxonline-api-axios/v2"
import type {
Order,
OrdersApiOrdersHistoryListRequest,
PaginatedOrderHistoryList,
} from "@mitodl/mitxonline-api-axios/v2"

const orderKeys = {
root: ["mitxonline", "orders"],
receipt: (orderId: number) => [...orderKeys.root, "receipt", orderId],
historyList: (opts: OrdersApiOrdersHistoryListRequest) => [
...orderKeys.root,
"history",
opts,
],
}

const orderQueries = {
Expand All @@ -19,6 +28,20 @@ const orderQueries = {
.then((res) => res.data)
},
}),
/**
* Fulfilled and refunded orders, most recent first.
*
* Enrollments carry no reference to their order, so getting from a run or
* program to its receipt means searching these lines — see
* `useOrderIdForRun` / `useOrderIdForProgram`.
*/
historyList: (opts: OrdersApiOrdersHistoryListRequest = {}) =>
queryOptions({
queryKey: orderKeys.historyList(opts),
queryFn: async (): Promise<PaginatedOrderHistoryList> => {
return ordersApi.ordersHistoryList(opts).then((res) => res.data)
},
}),
Comment on lines +38 to +44
}

export { orderQueries, orderKeys }
121 changes: 117 additions & 4 deletions frontends/api/src/mitxonline/test-utils/factories/orders.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { faker } from "@faker-js/faker/locale/en"
import type { Order, TransactionLine } from "@mitodl/mitxonline-api-axios/v2"
import type {
Line,
Nested,
Order,
OrderHistory,
OrderStreetAddress,
OrderTransactions,
PaginatedOrderHistoryList,
Product,
RedeemedDiscount,
TransactionLine,
} from "@mitodl/mitxonline-api-axios/v2"

const transactionLine = (
overrides: Partial<TransactionLine> = {},
Expand All @@ -17,6 +28,43 @@ const transactionLine = (
...overrides,
})

const orderTransactions = (
overrides: Partial<OrderTransactions> = {},
): OrderTransactions => ({
card_number: `xxxxxxxxxxxx${faker.string.numeric(4)}`,
card_type: "Visa",
name: faker.person.fullName(),
bill_to_email: faker.internet.email(),
payment_method: "card",
...overrides,
})

const orderStreetAddress = (
overrides: Partial<OrderStreetAddress> = {},
): OrderStreetAddress => ({
line: [faker.location.streetAddress()],
postal_code: faker.location.zipCode(),
state: faker.location.state({ abbreviated: true }),
city: faker.location.city(),
country: "US",
...overrides,
})

const redeemedDiscount = (
overrides: Partial<Nested> = {},
): RedeemedDiscount => ({
redeemed_discount: {
id: faker.number.int(),
created_on: faker.date.past().toISOString(),
updated_on: faker.date.past().toISOString(),
amount: faker.commerce.price({ min: 5, max: 50 }),
discount_type: "dollars-off",
redemption_type: "one-time",
discount_code: faker.string.alphanumeric(12),
...overrides,
},
})

const order = (overrides: Partial<Order> = {}): Order => ({
id: faker.number.int(),
state: "fulfilled",
Expand All @@ -27,9 +75,74 @@ const order = (overrides: Partial<Order> = {}): Order => ({
refunds: [],
reference_number: faker.string.alphanumeric(10),
created_on: faker.date.past().toISOString(),
transactions: {},
street_address: {},
transactions: orderTransactions(),
street_address: orderStreetAddress(),
...overrides,
})

/**
* The default `purchasable_object` has only an `id`, which matches no variant —
* pass a shaped object (with `course`, or neither `course` nor `run_tag`) when the
* test needs it to resolve.
*/
const product = (overrides: Partial<Product> = {}): Product => ({
id: faker.number.int(),
price: faker.commerce.price({ min: 50, max: 500 }),
description: faker.commerce.productDescription(),
is_active: true,
purchasable_object: { id: faker.number.int() },
...overrides,
})

const line = (overrides: Partial<Line> = {}): Line => {
const unitPrice = faker.commerce.price({ min: 50, max: 500 })
return {
id: faker.number.int(),
quantity: 1,
item_description: faker.commerce.productName(),
unit_price: unitPrice,
total_price: unitPrice,
product: product(),
...overrides,
}
}

const orderHistory = (overrides: Partial<OrderHistory> = {}): OrderHistory => ({
id: faker.number.int(),
state: "fulfilled",
reference_number: faker.string.alphanumeric(10),
purchaser: {
id: faker.number.int(),
name: faker.person.fullName(),
created_on: faker.date.past().toISOString(),
updated_on: faker.date.past().toISOString(),
},
total_price_paid: faker.commerce.price({ min: 50, max: 500 }),
lines: [line()],
created_on: faker.date.past().toISOString(),
titles: [],
updated_on: faker.date.past().toISOString(),
...overrides,
})

export { order, transactionLine }
const orderHistoryList = (
results: OrderHistory[],
opts: { count?: number; next?: string | null; previous?: string | null } = {},
): PaginatedOrderHistoryList => ({
count: opts.count ?? results.length,
next: opts.next ?? null,
previous: opts.previous ?? null,
results,
})

export {
order,
orderHistory,
orderHistoryList,
orderStreetAddress,
orderTransactions,
line,
product,
redeemedDiscount,
transactionLine,
}
3 changes: 3 additions & 0 deletions frontends/api/src/mitxonline/test-utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CoursesApiCourseVariantRunsV3Request,
CourseCertificatesApiCourseCertificatesRetrieveRequest,
ProgramCertificatesApiProgramCertificatesRetrieveRequest,
OrdersApiOrdersHistoryListRequest,
ProgramCollectionsApiProgramCollectionsListRequest,
ProgramsApiProgramsListV2Request,
} from "@mitodl/mitxonline-api-axios/v2"
Expand Down Expand Up @@ -155,6 +156,8 @@ const baskets = {
const orders = {
receipt: (orderId: number) =>
`${getApiBaseUrl()}/api/v0/orders/receipt/${orderId}/`,
historyList: (params?: OrdersApiOrdersHistoryListRequest) =>
`${getApiBaseUrl()}/api/v0/orders/history/${queryify(params)}`,
}

const verifiedProgramEnrollments = {
Expand Down
1 change: 1 addition & 0 deletions frontends/main/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const nextConfig = {
// Turbopack filesystem caching is enabled by default in Next.js 16.1+
// Explicitly enable it for clarity (optional - already default)
turbopackFileSystemCacheForDev: true,
turbopackMemoryLimit: 2 * 1024 ** 3, // bytes; triggers Turbopack GC above ~2GB
},

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import { urls, factories } from "api/mitxonline-test-utils"
import {
createCoursesWithContractRuns,
createTestContracts,
setupOrderHistory,
setupOrgAndUser,
setupProgramsAndCourses,
setupOrgDashboardMocks,
setupProgramsAndCourses,
} from "./CoursewareDisplay/test-utils"
import {
CourseWithCourseRunsSerializerV2,
Expand All @@ -26,6 +27,11 @@ import { useFeatureFlagEnabled } from "posthog-js/react"
import { FeatureFlags } from "@/common/feature_flags"
import { contractAdminView } from "@/common/urls"

// Verified cards look up their order; default to none, tests override.
beforeEach(() => {
setupOrderHistory()
})

jest.mock("posthog-js/react", () => ({
...jest.requireActual("posthog-js/react"),
useFeatureFlagEnabled: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
import { HomeEnrollmentsDisplay } from "./HomeEnrollmentsDisplay"
import { CoursewareCard } from "./CoursewareCard"
import { buildCourseEntry } from "./model/dashboardViewModel"
import { dashboardCourse, setupEnrollments } from "./test-utils"
import {
dashboardCourse,
setupEnrollments,
setupOrderHistory,
} from "./test-utils"
import * as mitxonline from "api/mitxonline-test-utils"
import {
urls as testUrls,
Expand All @@ -29,6 +33,11 @@ import {
trackProgramUnenrolled,
} from "@/common/analytics/gtm"

// Verified cards look up their order; default to none, tests override.
beforeEach(() => {
setupOrderHistory()
})

jest.mock("posthog-js/react")
jest.mock("@/common/analytics/gtm", () => ({
trackCourseUnenrolled: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@ import {
} from "@/test-utils"
import * as mitxonline from "api/mitxonline-test-utils"
import { mitxonlineLegacyUrl } from "@/common/mitxonline"
import { receiptView } from "@/common/urls"
import { makeRequest } from "api/test-utils"
import { faker } from "@faker-js/faker/locale/en"
import moment from "moment"
import { EnrolledCourseCard } from "./EnrolledCourseCard"
import { setupOrderHistory } from "./test-utils"

// Verified cards look up their order; default to none, tests override.
beforeEach(() => {
setupOrderHistory()
})

const EnrollmentMode = {
Audit: "audit",
Expand Down Expand Up @@ -784,12 +791,13 @@ describe.each([
enrollment_mode: EnrollmentMode.Verified,
grades: [mitxonline.factories.enrollment.grade({ passed: true })],
})
setupOrderHistory({ runId: enrollment.run.id })
renderWithProviders(<EnrolledCourseCard enrollment={enrollment} />)
await user.click(
within(getCard()).getByRole("button", { name: "More options" }),
)
expect(
screen.getByRole("menuitem", { name: "Receipt" }),
await screen.findByRole("menuitem", { name: "Receipt" }),
).toBeInTheDocument()
})

Expand All @@ -809,30 +817,48 @@ describe.each([
).not.toBeInTheDocument()
})

test("Receipt links to correct MITx Online URL for verified enrollment", async () => {
test("Receipt links to the receipt for the order that paid for the run", async () => {
setupUserApis()
const runId = faker.number.int()
const runId = faker.number.int({ min: 1 })
setupOrderHistory({ runId, orderId: 87 })
const enrollment = mitxonline.factories.enrollment.courseEnrollment({
enrollment_mode: EnrollmentMode.Verified,
grades: [mitxonline.factories.enrollment.grade({ passed: true })],
run: { id: runId },
})
const windowOpenSpy = jest
.spyOn(window, "open")
.mockImplementation(() => null)

renderWithProviders(<EnrolledCourseCard enrollment={enrollment} />)
await user.click(
within(getCard()).getByRole("button", { name: "More options" }),
)
await user.click(screen.getByRole("menuitem", { name: "Receipt" }))

expect(windowOpenSpy).toHaveBeenCalledWith(
mitxonlineLegacyUrl(`/orders/receipt/by-run/${runId}/`),
"_blank",
"noopener,noreferrer",
expect(
await screen.findByRole("menuitem", { name: "Receipt" }),
).toHaveAttribute("href", receiptView(87))
})

// Verified does not imply a run-level order, so the item must stay hidden.
test("Receipt is hidden for a verified enrollment with no order behind it", async () => {
setupUserApis()
const runId = faker.number.int({ min: 1 })
// An order exists, but for a different run.
setupOrderHistory({ runId: runId + 1, orderId: 87 })
const enrollment = mitxonline.factories.enrollment.courseEnrollment({
enrollment_mode: EnrollmentMode.Verified,
grades: [mitxonline.factories.enrollment.grade({ passed: true })],
run: { id: runId },
})

renderWithProviders(<EnrolledCourseCard enrollment={enrollment} />)
await user.click(
within(getCard()).getByRole("button", { name: "More options" }),
)
windowOpenSpy.mockRestore()
// Wait for a sibling item so we know the menu has rendered.
await screen.findByRole("menuitem", { name: "Unenroll" })

expect(
screen.queryByRole("menuitem", { name: "Receipt" }),
).not.toBeInTheDocument()
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { coursePageView } from "@/common/urls"
import NiceModal from "@ebay/nice-modal-react"
import { EmailSettingsDialog, UnenrollDialog } from "./DashboardDialogs"
import { getReceiptMenuItem } from "./receiptMenuItem"
import { useOrderIdForRun } from "@/common/mitxonline/useOrderIdForResource"
import {
CourseRunEnrollmentV3,
V3UserProgramEnrollment,
Expand Down Expand Up @@ -251,6 +252,17 @@ export const EnrolledCourseCard = ({
) : null
const mitxOnlineUser = useQuery(mitxUserQueries.me())
const isStaff = mitxOnlineUser.data?.is_staff
/**
* Only verified enrollments can have a receipt, so the lookup is skipped
* entirely for audit ones. Every card shares one `orders/history` query (same
* cache key), so this is a single request for the whole dashboard rather than
* one per card.
*/
const { orderId: receiptOrderId } = useOrderIdForRun(
isVerifiedEnrollmentMode(enrollment?.enrollment_mode)
? (run?.id ?? null)
: null,
)
const title = isCompact ? course.title : run?.title || course.title
const coursewareUrl = run?.courseware_url
const certificateLink = getCertificateLink(
Expand Down Expand Up @@ -455,7 +467,7 @@ export const EnrolledCourseCard = ({

const receiptMenuItem = getReceiptMenuItem(
enrollment?.enrollment_mode,
`/orders/receipt/by-run/${enrollment?.run.id}/`,
receiptOrderId,
)
if (receiptMenuItem) menuItems.push(receiptMenuItem)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ import {
import { HomeEnrollmentsDisplay } from "./HomeEnrollmentsDisplay"
import * as mitxonline from "api/mitxonline-test-utils"
import { useFeatureFlagEnabled } from "posthog-js/react"
import { setupEnrollments } from "./test-utils"
import { setupEnrollments, setupOrderHistory } from "./test-utils"
import { faker } from "@faker-js/faker/locale/en"

// Verified cards look up their order; default to none, tests override.
beforeEach(() => {
setupOrderHistory()
})

jest.mock("posthog-js/react")
const mockedUseFeatureFlagEnabled = jest
.mocked(useFeatureFlagEnabled)
Expand Down
Loading
Loading