diff --git a/frontends/api/src/mitxonline/hooks/orders/queries.ts b/frontends/api/src/mitxonline/hooks/orders/queries.ts index 87304be885..518c39c8bd 100644 --- a/frontends/api/src/mitxonline/hooks/orders/queries.ts +++ b/frontends/api/src/mitxonline/hooks/orders/queries.ts @@ -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 = { @@ -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 => { + return ordersApi.ordersHistoryList(opts).then((res) => res.data) + }, + }), } export { orderQueries, orderKeys } diff --git a/frontends/api/src/mitxonline/test-utils/factories/orders.ts b/frontends/api/src/mitxonline/test-utils/factories/orders.ts index b5b004e4da..42eea82f6f 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/orders.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/orders.ts @@ -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 = {}, @@ -17,6 +28,43 @@ const transactionLine = ( ...overrides, }) +const orderTransactions = ( + overrides: Partial = {}, +): 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 => ({ + 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 = {}, +): 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 => ({ id: faker.number.int(), state: "fulfilled", @@ -27,9 +75,74 @@ const order = (overrides: Partial = {}): 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 => ({ + 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 => { + 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 => ({ + 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, +} diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 3983dc05b6..6009de6460 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -3,6 +3,7 @@ import type { CoursesApiCourseVariantRunsV3Request, CourseCertificatesApiCourseCertificatesRetrieveRequest, ProgramCertificatesApiProgramCertificatesRetrieveRequest, + OrdersApiOrdersHistoryListRequest, ProgramCollectionsApiProgramCollectionsListRequest, ProgramsApiProgramsListV2Request, } from "@mitodl/mitxonline-api-axios/v2" @@ -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 = { diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 7c75ab5807..9ecc3e0d85 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -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 }, /** diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index 9b321fa855..5cdeca9724 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -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, @@ -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(), diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx index c46cbb4a4b..87e6a0eab6 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx @@ -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, @@ -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(), diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx index 0f829c1d10..ec74170235 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx @@ -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", @@ -784,12 +791,13 @@ describe.each([ enrollment_mode: EnrollmentMode.Verified, grades: [mitxonline.factories.enrollment.grade({ passed: true })], }) + setupOrderHistory({ runId: enrollment.run.id }) renderWithProviders() await user.click( within(getCard()).getByRole("button", { name: "More options" }), ) expect( - screen.getByRole("menuitem", { name: "Receipt" }), + await screen.findByRole("menuitem", { name: "Receipt" }), ).toBeInTheDocument() }) @@ -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() 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() + 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() }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx index ef5e89fa63..df5f75d4b9 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx @@ -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, @@ -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( @@ -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) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx index 3cc426c761..6ddcd1d085 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx @@ -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) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.test.tsx index fe38d3abd9..aa2b43178e 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.test.tsx @@ -2,8 +2,15 @@ import React from "react" import { renderWithProviders, screen, user, within } from "@/test-utils" import * as mitxonline from "api/mitxonline-test-utils" import { mitxonlineLegacyUrl } from "@/common/mitxonline" +import { receiptView } from "@/common/urls" import { DisplayModeEnum } from "@mitodl/mitxonline-api-axios/v2" import { ProgramEnrollmentCard } from "./ProgramEnrollmentCard" +import { setupOrderHistory } from "./test-utils" + +// Verified cards look up their order; default to none, tests override. +beforeEach(() => { + setupOrderHistory() +}) describe.each([ { display: "desktop", testId: "enrollment-card-desktop" }, @@ -227,6 +234,7 @@ describe.each([ mitxonline.factories.enrollment.programEnrollmentV3({ enrollment_mode: "verified", }) + setupOrderHistory({ programId: programEnrollment.program.id }) renderWithProviders( , ) @@ -234,7 +242,7 @@ describe.each([ within(getCard()).getByRole("button", { name: "More options" }), ) expect( - screen.getByRole("menuitem", { name: "Receipt" }), + await screen.findByRole("menuitem", { name: "Receipt" }), ).toBeInTheDocument() }) @@ -254,29 +262,45 @@ describe.each([ ).not.toBeInTheDocument() }) - test("Receipt links to correct MITx Online URL for verified program enrollment", async () => { + test("Receipt links to the receipt for the order that paid for the program", async () => { const program = mitxonline.factories.programs.simpleProgram({ id: 99 }) + setupOrderHistory({ programId: 99, orderId: 23 }) const programEnrollment = mitxonline.factories.enrollment.programEnrollmentV3({ program, enrollment_mode: "verified", }) - const windowOpenSpy = jest - .spyOn(window, "open") - .mockImplementation(() => null) renderWithProviders( , ) 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-program/99/"), - "_blank", - "noopener,noreferrer", + expect( + await screen.findByRole("menuitem", { name: "Receipt" }), + ).toHaveAttribute("href", receiptView(23)) + }) + + test("Receipt is hidden for a verified program enrollment with no order", async () => { + const program = mitxonline.factories.programs.simpleProgram({ id: 99 }) + // An order exists, but for a different program. + setupOrderHistory({ programId: 100, orderId: 23 }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program, + enrollment_mode: "verified", + }) + renderWithProviders( + , + ) + await user.click( + within(getCard()).getByRole("button", { name: "More options" }), ) - windowOpenSpy.mockRestore() + await screen.findByRole("menuitem", { name: "Program Record" }) + + expect( + screen.queryByRole("menuitem", { name: "Receipt" }), + ).not.toBeInTheDocument() }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx index 6c3ccc026c..617da8cfe4 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx @@ -25,6 +25,7 @@ import { getCertificateLink } from "./model/dashboardViewModel" import NiceModal from "@ebay/nice-modal-react" import { UnenrollProgramDialog } from "./DashboardDialogs" import { getReceiptMenuItem } from "./receiptMenuItem" +import { useOrderIdForProgram } from "@/common/mitxonline/useOrderIdForResource" import { SimpleMenu, Stack } from "ol-components" import { EnrollmentStatus } from "./helpers" import { ProgressBadge } from "./ProgressBadge" @@ -55,6 +56,13 @@ export const ProgramEnrollmentCard = ({ const upgradedAndIncomplete = isVerifiedEnrollmentMode( programEnrollment.enrollment_mode, ) + /** + * Skipped for audit enrollments, which never have a receipt. Shares one + * `orders/history` query with every other card on the dashboard. + */ + const { orderId: receiptOrderId } = useOrderIdForProgram( + upgradedAndIncomplete ? programId : null, + ) const displayMode = program.display_mode const titleSection = ( @@ -114,7 +122,7 @@ export const ProgramEnrollmentCard = ({ } const receiptMenuItem = getReceiptMenuItem( programEnrollment.enrollment_mode, - `/orders/receipt/by-program/${program.id}/`, + receiptOrderId, ) if (receiptMenuItem) menuItems.push(receiptMenuItem) const contextMenu = ( diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx index b60b41b513..b83b05025b 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx @@ -28,10 +28,16 @@ import { import { ProgramEnrollmentDisplay } from "./ProgramEnrollmentDisplay" import * as mitxonline from "api/mitxonline-test-utils" import { makeRequest } from "api/test-utils" +import { setupOrderHistory } from "./test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" +// Verified cards look up their order; default to none, tests override. +beforeEach(() => { + setupOrderHistory() +}) + jest.mock("posthog-js/react") const mockedUseFeatureFlagEnabled = jest .mocked(useFeatureFlagEnabled) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.test.ts index ac90a57595..f98dc93bd9 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.test.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.test.ts @@ -2,10 +2,25 @@ import { getReceiptMenuItem } from "./receiptMenuItem" describe("getReceiptMenuItem", () => { test("returns null when enrollment mode is undefined", () => { - const menuItem = getReceiptMenuItem( - undefined, - "/orders/receipt/by-program/99/", + expect(getReceiptMenuItem(undefined, 87)).toBeNull() + }) + + test("returns null for audit enrollments, since auditing is free", () => { + expect(getReceiptMenuItem("audit", 87)).toBeNull() + }) + + // Also covers "lookup still pending" — the hook reports null for both. + test("returns null for a verified enrollment with no resolved order", () => { + expect(getReceiptMenuItem("verified", null)).toBeNull() + }) + + test("links straight to the resolved receipt for verified enrollments", () => { + expect(getReceiptMenuItem("verified", 87)).toEqual( + expect.objectContaining({ + key: "receipt", + label: "Receipt", + href: "/receipt/87", + }), ) - expect(menuItem).toBeNull() }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.ts index 3b3e77244d..f3be3988b1 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.ts @@ -1,26 +1,28 @@ import { SimpleMenuItem } from "ol-components" -import { - isVerifiedEnrollmentMode, - mitxonlineLegacyUrl, -} from "@/common/mitxonline" +import { isVerifiedEnrollmentMode } from "@/common/mitxonline" +import { receiptView } from "@/common/urls" +/** + * The "Receipt" item for a dashboard card, or null when there is nothing to link + * to. + * + * Verified track alone is not enough — a program purchase can upgrade an existing + * audit enrollment without creating any order, leaving no receipt. `orderId` is + * also null while the lookup is pending, so the item appears only once an order is + * confirmed. + */ const getReceiptMenuItem = ( enrollmentMode: string | null | undefined, - receiptPath: string, + orderId: number | null, ): SimpleMenuItem | null => { if (!enrollmentMode || !isVerifiedEnrollmentMode(enrollmentMode)) return null + if (orderId === null) return null return { className: "dashboard-card-menu-item", key: "receipt", label: "Receipt", - onClick: () => { - window.open( - mitxonlineLegacyUrl(receiptPath), - "_blank", - "noopener,noreferrer", - ) - }, + href: receiptView(orderId), } } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts index a9269a57a9..ec4cfb8cd4 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts @@ -25,6 +25,67 @@ const makeCourseEnrollment = factories.enrollment.courseEnrollment const makeGrade = factories.enrollment.grade const makeContract = factories.contracts.contract +/** + * Mock the order history that verified enrollment cards fetch to decide whether to + * show a "Receipt" item. Required in any suite rendering a verified enrollment, + * or the unmocked request fails the test. Defaults to an empty history (no + * receipt); pass `runId`/`programId` to make one resolve. + */ +const setupOrderHistory = ({ + runId, + programId, + orderId = faker.number.int({ min: 1 }), +}: { + runId?: number + programId?: number + orderId?: number +} = {}) => { + const lines = [] + if (runId !== undefined) { + lines.push( + factories.orders.line({ + product: factories.orders.product({ + purchasable_object: { + id: runId, + title: "Some Run", + course: { id: faker.number.int(), title: "Some Course" }, + }, + }), + }), + ) + } + if (programId !== undefined) { + lines.push( + factories.orders.line({ + product: factories.orders.product({ + purchasable_object: { + id: programId, + title: "Some Program", + readable_id: "program-v1:MITxT+SysEng", + }, + }), + }), + ) + } + + setMockResponse.get( + urls.orders.historyList({ limit: 100 }), + factories.orders.orderHistoryList( + lines.length > 0 + ? [ + factories.orders.orderHistory({ + id: orderId, + state: "fulfilled", + lines, + }), + ] + : [], + ), + ) + + return { orderId } +} + const dashboardCourse: PartialFactory = ( ...overrides ) => { @@ -563,6 +624,7 @@ const buildProgramScenario = ( export { dashboardCourse, dashboardProgram, + setupOrderHistory, setupEnrollments, setupProgramsAndCourses, setupOrgAndUser, diff --git a/frontends/main/src/app-pages/ReceiptPage/ReceiptDetailList.tsx b/frontends/main/src/app-pages/ReceiptPage/ReceiptDetailList.tsx new file mode 100644 index 0000000000..a84bde39d8 --- /dev/null +++ b/frontends/main/src/app-pages/ReceiptPage/ReceiptDetailList.tsx @@ -0,0 +1,117 @@ +import React from "react" +import { Typography, styled } from "ol-components" + +/** A label/value pair. A nullish `value` means the row is dropped. */ +type ReceiptDetail = { + label: string + value: React.ReactNode +} + +/** Rows rendered contiguously; groups are separated by vertical space. */ +type ReceiptDetailGroup = ReceiptDetail[] + +const LABEL_COLUMN_WIDTH = "200px" + +const GroupStack = styled.div({ + display: "flex", + flexDirection: "column", + gap: "34px", +}) + +/** + * One grid per group. The label column is a fixed width so separate grids still + * align, which avoids putting spacer elements inside a `dl`. + */ +const DetailList = styled.dl(({ theme }) => ({ + display: "grid", + gridTemplateColumns: `${LABEL_COLUMN_WIDTH} minmax(0, 1fr)`, + margin: 0, + [theme.breakpoints.down("sm")]: { + gridTemplateColumns: "minmax(0, 1fr)", + }, +})) + +/** Border on both cells so the rule spans the full row. */ +const cell = { + display: "flex", + alignItems: "center", + margin: 0, + padding: "8px 16px 8px 0", + minHeight: "34px", + boxSizing: "border-box" as const, +} + +const DetailLabel = styled.dt(({ theme }) => ({ + ...cell, + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, + [theme.breakpoints.down("sm")]: { + borderBottom: "none", + paddingBottom: 0, + minHeight: "unset", + }, +})) + +const DetailValue = styled.dd(({ theme }) => ({ + ...cell, + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, + wordBreak: "break-word", +})) + +/** + * Drop rows the API did not supply, then drop any group left with no rows. + * + * MITx Online leaves optional receipt fields unset rather than blank — CEUs is + * always null today, and orders that never reached the payment processor (any + * zero-value order — 100%-off coupon, program entitlement) come back with every + * payment and address field null. A + * labelled empty row reads as missing data, and a whole section of them reads as + * a broken page, so callers use this to decide whether to render the section at + * all. + */ +const populatedGroups = (groups: ReceiptDetailGroup[]): ReceiptDetailGroup[] => + groups + .map((group) => + group.filter( + ({ value }) => value !== null && value !== undefined && value !== "", + ), + ) + .filter((group) => group.length > 0) + +/** + * The two-column label/value table used by each section of the receipt. Renders + * nothing when every row was filtered out; see {@link populatedGroups}. + */ +const ReceiptDetailList: React.FC<{ + groups: ReceiptDetailGroup[] + className?: string +}> = ({ groups, className }) => { + const populated = populatedGroups(groups) + + return ( + + {populated.map((group) => ( + // Groups are positional; the first label is a stable enough identity + // for a list that is rebuilt wholesale whenever the order changes. + + {group.map(({ label, value }) => ( + + + + {label} + + + + + {value} + + + + ))} + + ))} + + ) +} + +export { ReceiptDetailList, LABEL_COLUMN_WIDTH, populatedGroups } +export type { ReceiptDetail, ReceiptDetailGroup } diff --git a/frontends/main/src/app-pages/ReceiptPage/ReceiptOrderSummary.tsx b/frontends/main/src/app-pages/ReceiptPage/ReceiptOrderSummary.tsx new file mode 100644 index 0000000000..b84b0e411b --- /dev/null +++ b/frontends/main/src/app-pages/ReceiptPage/ReceiptOrderSummary.tsx @@ -0,0 +1,118 @@ +import React from "react" +import { Typography, styled } from "ol-components" +import type { Order } from "@mitodl/mitxonline-api-axios/v2" +import { formatMoney } from "./receiptUtils" + +const SummaryCard = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "24px", + padding: "16px", + borderRadius: "8px", + backgroundColor: theme.custom.colors.white, + boxShadow: + "0px 2px 4px 0px rgba(37, 38, 43, 0.10), 0px 3px 8px 0px rgba(37, 38, 43, 0.12)", +})) + +const Rows = styled.div({ + display: "flex", + flexDirection: "column", + gap: "16px", +}) + +const Row = styled.div({ + display: "flex", + gap: "16px", + alignItems: "flex-start", +}) + +const RowLabel = styled(Typography)({ + flex: "1 0 0", + minWidth: 0, +}) + +const RowValue = styled(Typography)({ + flexShrink: 0, + textAlign: "right", +}) + +const DiscountValue = styled(RowValue)(({ theme }) => ({ + color: theme.custom.colors.green, +})) + +const Rule = styled.hr(({ theme }) => ({ + border: "none", + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, + margin: 0, +})) + +const TotalRow = styled.div({ + display: "flex", + justifyContent: "space-between", + gap: "16px", +}) + +/** `line.discount` is per unit, hence the multiply. */ +const getTotalDiscount = (order: Order): number => + order.lines.reduce( + (total, line) => total + Number(line.discount) * line.quantity, + 0, + ) + +const getTotalQuantity = (order: Order): number => + order.lines.reduce((total, line) => total + line.quantity, 0) + +/** + * Per-item prices, discount and quantity totals, and the amount paid. The + * design's "Tax" and "Total Before Tax" rows are omitted — the payload has no tax + * data. + */ +const ReceiptOrderSummary: React.FC<{ order: Order; className?: string }> = ({ + order, + className, +}) => { + const totalDiscount = getTotalDiscount(order) + const totalQuantity = getTotalQuantity(order) + + return ( + + + Order Summary + + + {order.lines.map((line, index) => ( + // Receipt lines carry no id, and the same product can legitimately + // appear twice, so position is the only stable key available. + // eslint-disable-next-line react/no-array-index-key + + {line.content_title} + {formatMoney(line.price)} + + ))} + {totalDiscount > 0 ? ( + + Discount + + {`- ${formatMoney(totalDiscount)}`} + + + ) : null} + + Quantity + {`x ${totalQuantity}`} + + + + + Total: + + + {formatMoney(order.total_price_paid)} + + + + + ) +} + +export { ReceiptOrderSummary, getTotalDiscount, getTotalQuantity } diff --git a/frontends/main/src/app-pages/ReceiptPage/ReceiptPage.test.tsx b/frontends/main/src/app-pages/ReceiptPage/ReceiptPage.test.tsx new file mode 100644 index 0000000000..2730b1227f --- /dev/null +++ b/frontends/main/src/app-pages/ReceiptPage/ReceiptPage.test.tsx @@ -0,0 +1,386 @@ +import React from "react" +import { + renderWithProviders, + screen, + setMockResponse, + within, +} from "@/test-utils" +import * as mitxonline from "api/mitxonline-test-utils" +import ReceiptPage from "./ReceiptPage" + +const ORDER_ID = 4242 + +const setupApis = ({ + order, + user, +}: { + order?: ReturnType + user?: ReturnType +} = {}) => { + const mitxUser = user ?? mitxonline.factories.user.user() + const receipt = order ?? mitxonline.factories.orders.order({ id: ORDER_ID }) + setMockResponse.get(mitxonline.urls.userMe.get(), mitxUser) + setMockResponse.get(mitxonline.urls.orders.receipt(ORDER_ID), receipt) + return { mitxUser, receipt } +} + +/** Reads a label's value cell within one section, since labels repeat. */ +const findValueFor = async (sectionName: string, label: string) => { + const heading = await screen.findByRole("heading", { name: sectionName }) + // The heading and its detail list are siblings inside the section element. + const section = heading.closest("section") + if (!section) throw new Error(`No section found for "${sectionName}"`) + const term = within(section).getByText(label) + const value = term.closest("dt")?.nextElementSibling + if (!value) throw new Error(`No value cell found for "${label}"`) + return value +} + +describe("ReceiptPage", () => { + test("renders order information from the receipt", async () => { + const line = mitxonline.factories.orders.transactionLine({ + content_title: "The Iterative Innovation Process", + readable_id: "program-v1:xPRO+SysEngx", + start_date: "2024-09-01T00:00:00Z", + end_date: "2025-12-24T00:00:00Z", + price: "1524.60", + quantity: 1, + discount: "0.00", + CEUs: "20", + }) + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + lines: [line], + reference_number: "xpro-b2c-production-66238", + created_on: "2024-06-14T00:00:00Z", + total_price_paid: "1524.60", + }), + }) + + renderWithProviders() + + expect( + await screen.findByRole("heading", { name: "Receipt", level: 1 }), + ).toBeInTheDocument() + + expect( + await findValueFor("Order Information", "Order Item:"), + ).toHaveTextContent("The Iterative Innovation Process") + expect(await findValueFor("Order Information", "Dates:")).toHaveTextContent( + "September 01, 2024 - December 24, 2025", + ) + expect( + await findValueFor("Order Information", "Order Number:"), + ).toHaveTextContent("xpro-b2c-production-66238") + expect( + await findValueFor("Order Information", "Order Date:"), + ).toHaveTextContent("June 14, 2024") + expect( + await findValueFor("Order Information", "Unit Price:"), + ).toHaveTextContent("$1,524.60") + expect( + await findValueFor("Order Information", "Total Paid:"), + ).toHaveTextContent("$1,524.60") + expect( + await findValueFor("Order Information", "Product Number:"), + ).toHaveTextContent("program-v1:xPRO+SysEngx") + expect(await findValueFor("Order Information", "CEUs:")).toHaveTextContent( + "20", + ) + }) + + test("formats dates in UTC so they match the recorded order", async () => { + // Local formatting would render these a day early. + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + created_on: "2026-07-09T00:00:00Z", + lines: [ + mitxonline.factories.orders.transactionLine({ + start_date: "2026-07-08T00:00:00Z", + end_date: "2027-07-09T00:00:00Z", + }), + ], + }), + }) + + renderWithProviders() + + expect(await findValueFor("Order Information", "Dates:")).toHaveTextContent( + "July 08, 2026 - July 09, 2027", + ) + expect( + await findValueFor("Order Information", "Order Date:"), + ).toHaveTextContent("July 09, 2026") + }) + + test("shows only the known end of the range when a run has no end date", async () => { + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + lines: [ + mitxonline.factories.orders.transactionLine({ + start_date: "2026-06-22T00:00:00Z", + end_date: null as unknown as string, + }), + ], + }), + }) + + renderWithProviders() + + expect(await findValueFor("Order Information", "Dates:")).toHaveTextContent( + "June 22, 2026", + ) + }) + + test("shows cents on whole-dollar amounts", async () => { + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + lines: [ + mitxonline.factories.orders.transactionLine({ price: "500.00" }), + ], + total_price_paid: "500.00", + }), + }) + + renderWithProviders() + + expect( + await findValueFor("Order Information", "Unit Price:"), + ).toHaveTextContent("$500.00") + }) + + test("takes the customer name and email from the MITx Online user", async () => { + const { mitxUser } = setupApis({ + user: mitxonline.factories.user.user({ + name: "Peter Pinch", + email: "pdpinch@mit.edu", + }), + }) + + renderWithProviders() + + expect( + await findValueFor("Customer Information", "Name:"), + ).toHaveTextContent(mitxUser.name!) + expect( + await findValueFor("Customer Information", "Email:"), + ).toHaveTextContent("pdpinch@mit.edu") + }) + + test("renders the billing address as a single line", async () => { + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + street_address: { + line: ["123 Main Street"], + city: "Danvers", + state: "MA", + postal_code: "01923", + country: "US", + }, + }), + }) + + renderWithProviders() + + expect( + await findValueFor("Customer Information", "Address:"), + ).toHaveTextContent("123 Main Street, Danvers MA, 01923, US") + }) + + test("renders card payment details", async () => { + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + transactions: { + payment_method: "card", + card_type: "Visa", + card_number: "xxxxxxxxxxxx1111", + name: "Peter Pinch", + }, + }), + }) + + renderWithProviders() + + expect( + await findValueFor("Payment Information", "Payment Method:"), + ).toHaveTextContent("Visa | xxxxxxxxxxxx1111") + expect( + await findValueFor("Payment Information", "Name:"), + ).toHaveTextContent("Peter Pinch") + }) + + test("renders Paypal payments without card details", async () => { + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + transactions: { + payment_method: "paypal", + bill_to_email: "pdpinch@mit.edu", + name: "Peter Pinch", + }, + }), + }) + + renderWithProviders() + + expect( + await findValueFor("Payment Information", "Payment Method:"), + ).toHaveTextContent("Paypal") + }) + + test("shows the discount code when one was redeemed", async () => { + const discountCode = "30468acf5a4e4c3c9c31a262caf984c9" // pragma: allowlist secret + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + discounts: [ + mitxonline.factories.orders.redeemedDiscount({ + discount_code: discountCode, + }), + ], + }), + }) + + renderWithProviders() + + expect( + await findValueFor("Order Information", "Discount Code:"), + ).toHaveTextContent(discountCode) // + }) + + test("omits rows the receipt payload does not provide", async () => { + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + // No discount redeemed and no per-line discount. + discounts: [], + lines: [ + mitxonline.factories.orders.transactionLine({ + discount: "0.00", + // MITx Online hardcodes CEUs to null on receipts today. + CEUs: null as unknown as string, + }), + ], + street_address: {}, + }), + }) + + renderWithProviders() + + await screen.findByRole("heading", { name: "Order Information" }) + + expect(screen.queryByText("Discount Code:")).not.toBeInTheDocument() + expect(screen.queryByText("Discount:")).not.toBeInTheDocument() + expect(screen.queryByText("CEUs:")).not.toBeInTheDocument() + expect(screen.getByText("Address:")).toBeInTheDocument() // MIT Learn's own + expect( + within( + ( + await screen.findByRole("heading", { name: "Customer Information" }) + ).closest("section")!, + ).queryByText("Address:"), + ).not.toBeInTheDocument() + }) + + test("drops the Payment Information section when there is no transaction", async () => { + // Matches a real zero-value order: MITx Online returns the + // transaction and address objects with every field null. + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + transactions: { + card_number: undefined, + card_type: undefined, + name: undefined, + bill_to_email: undefined, + payment_method: undefined, + }, + }), + }) + + renderWithProviders() + + await screen.findByRole("heading", { name: "Order Information" }) + + expect( + screen.queryByRole("heading", { name: "Payment Information" }), + ).not.toBeInTheDocument() + }) + + test("renders the order summary with total and quantity", async () => { + setupApis({ + order: mitxonline.factories.orders.order({ + id: ORDER_ID, + lines: [ + mitxonline.factories.orders.transactionLine({ + content_title: "The Iterative Innovation Process", + price: "1524.60", + quantity: 2, + discount: "10.00", + }), + ], + total_price_paid: "3029.20", + }), + }) + + renderWithProviders() + + const summary = ( + await screen.findByRole("heading", { name: "Order Summary" }) + ).closest("div")! + + expect(summary).toHaveTextContent("The Iterative Innovation Process") + expect(summary).toHaveTextContent("x 2") + // 10.00 per unit × 2 units + expect(summary).toHaveTextContent("- $20.00") + expect(summary).toHaveTextContent("$3,029.20") + }) + + // Someone else's order 404s like a missing one; both get the generic 404. + test("renders the generic 404 when the order is not found or not yours", async () => { + setMockResponse.get( + mitxonline.urls.userMe.get(), + mitxonline.factories.user.user(), + ) + setMockResponse.get(mitxonline.urls.orders.receipt(ORDER_ID), "Not found", { + code: 404, + }) + + renderWithProviders() + + expect( + await screen.findByText(/couldn't find what you were looking for/i), + ).toBeInTheDocument() + expect( + screen.queryByText(/We could not load this receipt/i), + ).not.toBeInTheDocument() + }) + + // A 500 says nothing about existence, so it keeps an actionable message. + test("keeps an actionable message when the request fails for another reason", async () => { + setMockResponse.get( + mitxonline.urls.userMe.get(), + mitxonline.factories.user.user(), + ) + setMockResponse.get( + mitxonline.urls.orders.receipt(ORDER_ID), + "Server error", + { code: 500 }, + ) + + renderWithProviders() + + expect( + await screen.findByText(/We could not load this receipt/i), + ).toBeInTheDocument() + expect( + screen.getByRole("link", { name: "Back to Dashboard" }), + ).toHaveAttribute("href", "/dashboard") + }) +}) diff --git a/frontends/main/src/app-pages/ReceiptPage/ReceiptPage.tsx b/frontends/main/src/app-pages/ReceiptPage/ReceiptPage.tsx new file mode 100644 index 0000000000..66517449a6 --- /dev/null +++ b/frontends/main/src/app-pages/ReceiptPage/ReceiptPage.tsx @@ -0,0 +1,289 @@ +"use client" + +import React from "react" +import Image from "next/image" +import { useRouter } from "next-nprogress-bar" +import { useQuery } from "@tanstack/react-query" +import { RiArrowLeftLine, RiPrinterLine } from "@remixicon/react" +import { Container, Skeleton, Typography, styled } from "ol-components" +import { Button, ButtonLink } from "@mitodl/smoot-design" +import { orderQueries } from "api/mitxonline-hooks/orders" +import { mitxUserQueries } from "api/mitxonline-hooks/user" +import type { Order } from "@mitodl/mitxonline-api-axios/v2" +import type { AxiosError } from "axios" +import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage" +import mitLearnLogo from "@/public/images/mit-learn-logo-black.svg" +import { env } from "@/env" +import * as urls from "@/common/urls" +import { ReceiptDetailList, populatedGroups } from "./ReceiptDetailList" +import type { ReceiptDetailGroup } from "./ReceiptDetailList" +import { ReceiptOrderSummary } from "./ReceiptOrderSummary" +import { + formatDateRange, + formatMoney, + formatPaymentMethod, + formatReceiptDate, + formatStreetAddress, + getDiscountCode, +} from "./receiptUtils" + +const SUPPORT_EMAIL = env("NEXT_PUBLIC_MITOL_SUPPORT_EMAIL") || "" + +/** MIT Learn's own details — not learner data, not from the API. */ +const MIT_LEARN_ADDRESS = + "600 Technology Square, NE49-2000, Cambridge, MA 02139 USA" + +const Background = styled.div(({ theme }) => ({ + backgroundColor: theme.custom.colors.lightGray1, + minHeight: "100%", +})) + +const PageContainer = styled(Container)({ + paddingTop: "40px", + paddingBottom: "80px", +}) + +const ButtonBar = styled.div({ + display: "flex", + gap: "8px", + justifyContent: "space-between", + // Neither button belongs in a printed receipt. + "@media print": { + display: "none", + }, +}) + +const Columns = styled.div(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) 424px", + gap: "32px", + alignItems: "start", + paddingTop: "16px", + [theme.breakpoints.down("md")]: { + gridTemplateColumns: "minmax(0, 1fr)", + }, +})) + +const MainColumn = styled.div({ + display: "flex", + flexDirection: "column", + gap: "32px", +}) + +const Section = styled.section({ + display: "flex", + flexDirection: "column", + gap: "8px", +}) + +const IssuerLogo = styled(Image)({ + display: "block", + // `Section` stretches its children, which would scale the logo up. + alignSelf: "flex-start", + width: "auto", + height: "40px", +}) + +const TitleSection = styled.div({ + paddingTop: "32px", + paddingBottom: "4px", +}) + +const SupportLink = styled.a(({ theme }) => ({ + color: theme.custom.colors.red, + textDecoration: "none", + ":hover": { + textDecoration: "underline", + }, +})) + +/** + * One group of rows per line item, then the order-level rows. The design's "Tax" + * and "HSN" rows are omitted — the payload has no such fields. "CEUs" is wired up + * but MITx Online always returns null for it today. + */ +const getOrderDetailGroups = (order: Order): ReceiptDetailGroup[] => { + const lineGroups: ReceiptDetailGroup[] = order.lines.map((line) => [ + { label: "Order Item:", value: line.content_title }, + { label: "Dates:", value: formatDateRange(line.start_date, line.end_date) }, + { label: "Product Number:", value: line.readable_id }, + { label: "CEUs:", value: line.CEUs }, + { label: "Unit Price:", value: formatMoney(line.price) }, + { label: "Quantity:", value: line.quantity }, + { + label: "Discount:", + value: + Number(line.discount) > 0 ? `-${formatMoney(line.discount)}` : null, + }, + ]) + + return [ + ...lineGroups, + [ + { label: "Order Number:", value: order.reference_number }, + { label: "Order Date:", value: formatReceiptDate(order.created_on) }, + { label: "Discount Code:", value: getDiscountCode(order) }, + { label: "Total Paid:", value: formatMoney(order.total_price_paid) }, + ], + ] +} + +const ReceiptSkeleton: React.FC = () => ( + + + + + +) + +const ReceiptPage: React.FC<{ orderId: number }> = ({ orderId }) => { + const router = useRouter() + const orderQuery = useQuery(orderQueries.receipt(orderId)) + /** + * `Order.purchaser` has no name field. The endpoint only returns the requester's + * own orders, so the logged-in user is the purchaser. + */ + const userQuery = useQuery(mitxUserQueries.me()) + + const order = orderQuery.data + const user = userQuery.data + + /** + * Filtered up front so a section with no rows can be dropped along with its + * heading — zero-value orders have no payment or billing details at all. + */ + const customerGroups = order + ? populatedGroups([ + [ + { label: "Name:", value: user?.name }, + { label: "Email:", value: user?.email }, + { + label: "Address:", + value: formatStreetAddress(order.street_address), + }, + ], + ]) + : [] + + const paymentGroups = order + ? populatedGroups([ + [ + { label: "Name:", value: order.transactions?.name }, + { label: "Payment Method:", value: formatPaymentMethod(order) }, + ], + ]) + : [] + + /** + * The endpoint is purchaser-scoped, so someone else's order 404s just like a + * missing one. Both get the generic 404 so a prober cannot tell them apart. + * Other failures keep the message below, since they say nothing about whether + * the order exists. + */ + const isNotFound = + !orderQuery.isPending && + (orderQuery.error as AxiosError | null)?.response?.status === 404 + + if (isNotFound) { + return + } + + return ( + + + + + + + + + + Receipt + + + + {orderQuery.isPending ? ( + + ) : orderQuery.isError || !order ? ( + + + We could not load this receipt.{" "} + + Contact support + {" "} + if the problem persists. + + + Back to Dashboard + + + ) : ( + + +
+ + Order Information + + +
+ + {customerGroups.length > 0 ? ( +
+ + Customer Information + + +
+ ) : null} + + {paymentGroups.length > 0 ? ( +
+ + Payment Information + + +
+ ) : null} + +
+ + + {SUPPORT_EMAIL} + + ) : null, + }, + ], + ]} + /> +
+
+ + +
+ )} +
+
+ ) +} + +export default ReceiptPage +export { getOrderDetailGroups } diff --git a/frontends/main/src/app-pages/ReceiptPage/ReceiptRedirect.test.tsx b/frontends/main/src/app-pages/ReceiptPage/ReceiptRedirect.test.tsx new file mode 100644 index 0000000000..ea437087f8 --- /dev/null +++ b/frontends/main/src/app-pages/ReceiptPage/ReceiptRedirect.test.tsx @@ -0,0 +1,243 @@ +import React from "react" +import { + renderWithProviders, + screen, + setMockResponse, + waitFor, +} from "@/test-utils" +import * as mitxonline from "api/mitxonline-test-utils" +import { receiptView } from "@/common/urls" +import { + ReceiptByProgramRedirect, + ReceiptByRunRedirect, +} from "./ReceiptRedirect" + +const RUN_ID = 500 +const PROGRAM_ID = 99 +const ORDER_ID = 4242 + +/** Must match the hook's request exactly, params included. */ +const HISTORY_URL = mitxonline.urls.orders.historyList({ limit: 100 }) + +/** Course run: the only variant with `course`. */ +const courseRunLine = (runId: number) => + mitxonline.factories.orders.line({ + product: mitxonline.factories.orders.product({ + purchasable_object: { + id: runId, + title: "Some Run", + readable_id: "course-v1:MITxT+1.234", + course: { id: 7, title: "Some Course" }, + }, + }), + }) + +/** Program: neither `course` nor `run_tag`. */ +const programLine = (programId: number) => + mitxonline.factories.orders.line({ + product: mitxonline.factories.orders.product({ + purchasable_object: { + id: programId, + title: "Some Program", + readable_id: "program-v1:MITxT+SysEng", + }, + }), + }) + +/** Program run: has `run_tag`, and its `id` is the run's, not the program's. */ +const programRunLine = (programRunId: number) => + mitxonline.factories.orders.line({ + product: mitxonline.factories.orders.product({ + purchasable_object: { + id: programRunId, + run_tag: "R1", + start_date: "2024-09-01T00:00:00Z", + end_date: "2025-12-24T00:00:00Z", + }, + }), + }) + +describe("ReceiptByRunRedirect", () => { + test("redirects to the receipt for the order that paid for the run", async () => { + setMockResponse.get( + HISTORY_URL, + mitxonline.factories.orders.orderHistoryList([ + mitxonline.factories.orders.orderHistory({ + id: ORDER_ID, + state: "fulfilled", + lines: [courseRunLine(RUN_ID)], + }), + ]), + ) + + const { location } = renderWithProviders( + , + ) + + await waitFor(() => { + expect(location.current.pathname).toBe(receiptView(ORDER_ID)) + }) + }) + + test("picks the most recent matching order", async () => { + setMockResponse.get( + HISTORY_URL, + // MITx Online returns order history most-recent-first. + mitxonline.factories.orders.orderHistoryList([ + mitxonline.factories.orders.orderHistory({ + id: 999, + state: "fulfilled", + lines: [courseRunLine(RUN_ID)], + }), + mitxonline.factories.orders.orderHistory({ + id: 111, + state: "fulfilled", + lines: [courseRunLine(RUN_ID)], + }), + ]), + ) + + const { location } = renderWithProviders( + , + ) + + await waitFor(() => { + expect(location.current.pathname).toBe(receiptView(999)) + }) + }) + + test("ignores orders that were not fulfilled", async () => { + setMockResponse.get( + HISTORY_URL, + mitxonline.factories.orders.orderHistoryList([ + mitxonline.factories.orders.orderHistory({ + id: 999, + state: "canceled", + lines: [courseRunLine(RUN_ID)], + }), + mitxonline.factories.orders.orderHistory({ + id: ORDER_ID, + state: "fulfilled", + lines: [courseRunLine(RUN_ID)], + }), + ]), + ) + + const { location } = renderWithProviders( + , + ) + + await waitFor(() => { + expect(location.current.pathname).toBe(receiptView(ORDER_ID)) + }) + }) + + test("does not match a program whose id happens to equal the run id", async () => { + setMockResponse.get( + HISTORY_URL, + mitxonline.factories.orders.orderHistoryList([ + mitxonline.factories.orders.orderHistory({ + id: ORDER_ID, + state: "fulfilled", + lines: [programLine(RUN_ID)], + }), + ]), + ) + + renderWithProviders() + + expect( + await screen.findByText(/couldn't find what you were looking for/i), + ).toBeInTheDocument() + }) + + test("shows not found when no order references the run", async () => { + setMockResponse.get( + HISTORY_URL, + mitxonline.factories.orders.orderHistoryList([ + mitxonline.factories.orders.orderHistory({ + state: "fulfilled", + lines: [courseRunLine(RUN_ID + 1)], + }), + ]), + ) + + renderWithProviders() + + expect( + await screen.findByText(/couldn't find what you were looking for/i), + ).toBeInTheDocument() + }) + + // Indistinguishable from an absent receipt, on purpose. + test("shows the generic 404 when the order lookup fails", async () => { + setMockResponse.get(HISTORY_URL, "Server error", { code: 500 }) + + renderWithProviders() + + expect( + await screen.findByText(/couldn't find what you were looking for/i), + ).toBeInTheDocument() + }) +}) + +describe("ReceiptByProgramRedirect", () => { + test("redirects to the receipt for the order that paid for the program", async () => { + setMockResponse.get( + HISTORY_URL, + mitxonline.factories.orders.orderHistoryList([ + mitxonline.factories.orders.orderHistory({ + id: ORDER_ID, + state: "fulfilled", + lines: [programLine(PROGRAM_ID)], + }), + ]), + ) + + const { location } = renderWithProviders( + , + ) + + await waitFor(() => { + expect(location.current.pathname).toBe(receiptView(ORDER_ID)) + }) + }) + + test("does not match a program run whose id happens to equal the program id", async () => { + setMockResponse.get( + HISTORY_URL, + mitxonline.factories.orders.orderHistoryList([ + mitxonline.factories.orders.orderHistory({ + id: ORDER_ID, + state: "fulfilled", + lines: [programRunLine(PROGRAM_ID)], + }), + ]), + ) + + renderWithProviders() + + expect( + await screen.findByText(/couldn't find what you were looking for/i), + ).toBeInTheDocument() + }) + + test("does not match a course run whose id happens to equal the program id", async () => { + setMockResponse.get( + HISTORY_URL, + mitxonline.factories.orders.orderHistoryList([ + mitxonline.factories.orders.orderHistory({ + id: ORDER_ID, + state: "fulfilled", + lines: [courseRunLine(PROGRAM_ID)], + }), + ]), + ) + + renderWithProviders() + + expect( + await screen.findByText(/couldn't find what you were looking for/i), + ).toBeInTheDocument() + }) +}) diff --git a/frontends/main/src/app-pages/ReceiptPage/ReceiptRedirect.tsx b/frontends/main/src/app-pages/ReceiptPage/ReceiptRedirect.tsx new file mode 100644 index 0000000000..fecce8210a --- /dev/null +++ b/frontends/main/src/app-pages/ReceiptPage/ReceiptRedirect.tsx @@ -0,0 +1,67 @@ +"use client" + +import React from "react" +import { useRouter } from "next-nprogress-bar" +import { Container, Skeleton, styled } from "ol-components" +import * as urls from "@/common/urls" +import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage" +import { + useOrderIdForProgram, + useOrderIdForRun, +} from "@/common/mitxonline/useOrderIdForResource" +import type { OrderIdResolution } from "@/common/mitxonline/useOrderIdForResource" + +const PageContainer = styled(Container)({ + paddingTop: "40px", + paddingBottom: "80px", + display: "flex", + flexDirection: "column", + gap: "16px", +}) + +/** + * Resolves a course run or program to its order, then replaces the history entry + * with that order's receipt. `replace`, not `push`, so "Back" from the receipt + * skips this route. Mirrors MITx Online's `ReceiptByRunView`. + */ +const ReceiptRedirect: React.FC<{ resolution: OrderIdResolution }> = ({ + resolution, +}) => { + const router = useRouter() + const { isPending, orderId } = resolution + + React.useEffect(() => { + if (orderId !== null) { + router.replace(urls.receiptView(orderId)) + } + }, [orderId, router]) + + if (isPending || orderId !== null) { + return ( + + + + + ) + } + + /** + * No order covers this run/program, or the lookup failed. Generic 404 for both, + * so a prober cannot tell a well-formed id from an unresolvable one. + */ + return +} + +const ReceiptByRunRedirect: React.FC<{ runId: number }> = ({ runId }) => { + const resolution = useOrderIdForRun(runId) + return +} + +const ReceiptByProgramRedirect: React.FC<{ programId: number }> = ({ + programId, +}) => { + const resolution = useOrderIdForProgram(programId) + return +} + +export { ReceiptRedirect, ReceiptByRunRedirect, ReceiptByProgramRedirect } diff --git a/frontends/main/src/app-pages/ReceiptPage/receiptUtils.ts b/frontends/main/src/app-pages/ReceiptPage/receiptUtils.ts new file mode 100644 index 0000000000..f1d0e2f2bc --- /dev/null +++ b/frontends/main/src/app-pages/ReceiptPage/receiptUtils.ts @@ -0,0 +1,70 @@ +import moment from "moment" +import type { Order, OrderStreetAddress } from "@mitodl/mitxonline-api-axios/v2" +import { formatPrice } from "@/common/mitxonline" + +/** Receipt amounts always show cents, unlike catalog prices. */ +const formatMoney = (amount: number | string): string => + formatPrice(amount, { avoidCents: false }) + +/** + * Long-form receipt dates, e.g. "June 14, 2024". UTC, not the viewer's zone: + * course dates are stored at UTC midnight and would otherwise render a day early + * west of Greenwich. + */ +const formatReceiptDate = (date: string): string => + moment.utc(date).format("MMMM DD, YYYY") + +/** A line's dates as one range; either end may be absent. */ +const formatDateRange = ( + startDate?: string | null, + endDate?: string | null, +): string | null => { + const start = startDate ? formatReceiptDate(startDate) : null + const end = endDate ? formatReceiptDate(endDate) : null + if (start && end) return `${start} - ${end}` + return start ?? end +} + +/** + * Billing address as one line, e.g. "123 Main Street, Danvers MA, 01923". Every + * part is optional, so only present ones are joined. + */ +const formatStreetAddress = ( + address: OrderStreetAddress | undefined, +): string | null => { + if (!address) return null + const cityAndState = [address.city, address.state] + .filter(Boolean) + .join(" ") + .trim() + const parts = [ + ...(address.line ?? []), + cityAndState, + address.postal_code, + address.country, + ].filter((part): part is string => Boolean(part && part.trim())) + + return parts.length > 0 ? parts.join(", ") : null +} + +/** e.g. "Visa | xxxxxxxxxxxx1111", or null when there was no payment. */ +const formatPaymentMethod = (order: Order): string | null => { + const transaction = order.transactions + if (!transaction) return null + if (transaction.payment_method === "paypal") return "Paypal" + const parts = [transaction.card_type, transaction.card_number].filter(Boolean) + return parts.length > 0 ? parts.join(" | ") : null +} + +/** The discount code redeemed on the order, if any. */ +const getDiscountCode = (order: Order): string | null => + order.discounts[0]?.redeemed_discount?.discount_code ?? null + +export { + formatDateRange, + formatMoney, + formatPaymentMethod, + formatReceiptDate, + formatStreetAddress, + getDiscountCode, +} diff --git a/frontends/main/src/app/(site)/receipt/[orderId]/page.tsx b/frontends/main/src/app/(site)/receipt/[orderId]/page.tsx new file mode 100644 index 0000000000..6ca5a91e76 --- /dev/null +++ b/frontends/main/src/app/(site)/receipt/[orderId]/page.tsx @@ -0,0 +1,26 @@ +import React from "react" +import type { Metadata } from "next" +import { notFound } from "next/navigation" +import { standardizeMetadata } from "@/common/metadata" +import ReceiptPage from "@/app-pages/ReceiptPage/ReceiptPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "Receipt", + robots: { index: false }, +}) + +/** + * Fetched client-side: MITx Online's session cookie is not forwarded on + * server-side requests, so the order cannot be prefetched here. + */ +const Page: React.FC> = async ({ params }) => { + const { orderId } = await params + const id = Number(orderId) + if (!Number.isInteger(id) || id <= 0) { + notFound() + } + + return +} + +export default Page diff --git a/frontends/main/src/app/(site)/receipt/by-program/[programId]/page.tsx b/frontends/main/src/app/(site)/receipt/by-program/[programId]/page.tsx new file mode 100644 index 0000000000..42b597af6f --- /dev/null +++ b/frontends/main/src/app/(site)/receipt/by-program/[programId]/page.tsx @@ -0,0 +1,28 @@ +import React from "react" +import type { Metadata } from "next" +import { notFound } from "next/navigation" +import { standardizeMetadata } from "@/common/metadata" +import { ReceiptByProgramRedirect } from "@/app-pages/ReceiptPage/ReceiptRedirect" + +export const metadata: Metadata = standardizeMetadata({ + title: "Receipt", + robots: { index: false }, +}) + +/** + * Resolves a program to the order covering it and redirects to that order's + * receipt. See `ReceiptRedirect` for why this route exists. + */ +const Page: React.FC> = async ({ + params, +}) => { + const { programId } = await params + const id = Number(programId) + if (!Number.isInteger(id) || id <= 0) { + notFound() + } + + return +} + +export default Page diff --git a/frontends/main/src/app/(site)/receipt/by-run/[runId]/page.tsx b/frontends/main/src/app/(site)/receipt/by-run/[runId]/page.tsx new file mode 100644 index 0000000000..020ac0e2a8 --- /dev/null +++ b/frontends/main/src/app/(site)/receipt/by-run/[runId]/page.tsx @@ -0,0 +1,28 @@ +import React from "react" +import type { Metadata } from "next" +import { notFound } from "next/navigation" +import { standardizeMetadata } from "@/common/metadata" +import { ReceiptByRunRedirect } from "@/app-pages/ReceiptPage/ReceiptRedirect" + +export const metadata: Metadata = standardizeMetadata({ + title: "Receipt", + robots: { index: false }, +}) + +/** + * Resolves a course run to the order covering it and redirects to that order's + * receipt. See `ReceiptRedirect` for why this route exists. + */ +const Page: React.FC> = async ({ + params, +}) => { + const { runId } = await params + const id = Number(runId) + if (!Number.isInteger(id) || id <= 0) { + notFound() + } + + return +} + +export default Page diff --git a/frontends/main/src/common/mitxonline/useOrderIdForResource.ts b/frontends/main/src/common/mitxonline/useOrderIdForResource.ts new file mode 100644 index 0000000000..cd53ec709f --- /dev/null +++ b/frontends/main/src/common/mitxonline/useOrderIdForResource.ts @@ -0,0 +1,85 @@ +import { useQuery } from "@tanstack/react-query" +import { orderQueries } from "api/mitxonline-hooks/orders" +import type { + Line, + OrderHistory, + ProductPurchasableObject, +} from "@mitodl/mitxonline-api-axios/v2" +import { StateEnum } from "@mitodl/mitxonline-api-axios/v2" + +/** + * An explicit limit is required: without one, `orders/history` bypasses + * pagination and returns a bare array instead of `{count, results}`. We do not + * follow `next`, so orders past this many are not found. + */ +const ORDER_HISTORY_LIMIT = 100 + +type OrderIdResolution = { + isPending: boolean + /** Most recent fulfilled order covering the resource. May be zero-value. */ + orderId: number | null +} + +/** + * `purchasable_object` is an untagged union whose variants all expose a bare `id`, + * and those ids come from different tables — so match on shape, not id alone. + * Program-run products match neither guard, which is fine: their id is the run's, + * not the program's. + */ +const isCourseRun = (obj: ProductPurchasableObject): boolean => + "course" in obj && obj.course !== undefined + +const isProgram = (obj: ProductPurchasableObject): boolean => + !isCourseRun(obj) && !("run_tag" in obj && obj.run_tag !== undefined) + +const matchesLine = ( + line: Line, + resourceId: number, + isVariant: (obj: ProductPurchasableObject) => boolean, +): boolean => { + const purchased = line.product.purchasable_object + return purchased?.id === resourceId && isVariant(purchased) +} + +/** + * Most recent fulfilled order covering a resource. History comes back + * newest-first. Refunded orders are excluded, matching `ReceiptByRunView` — + * revisit when the refund section is built. + */ +const useOrderIdForResource = ( + resourceId: number | null, + isVariant: (obj: ProductPurchasableObject) => boolean, +): OrderIdResolution => { + const history = useQuery({ + ...orderQueries.historyList({ limit: ORDER_HISTORY_LIMIT }), + enabled: resourceId !== null, + }) + + if (resourceId === null) { + return { isPending: false, orderId: null } + } + if (history.isPending) { + return { isPending: true, orderId: null } + } + // Reported as "not found": callers render the same 404 either way. + if (history.isError || !history.data) { + return { isPending: false, orderId: null } + } + + const match = history.data.results.find( + (order: OrderHistory) => + order.state === StateEnum.Fulfilled && + order.lines.some((line) => matchesLine(line, resourceId, isVariant)), + ) + + return { isPending: false, orderId: match?.id ?? null } +} + +const useOrderIdForRun = (runId: number | null): OrderIdResolution => + useOrderIdForResource(runId, isCourseRun) + +const useOrderIdForProgram = (programId: number | null): OrderIdResolution => + useOrderIdForResource(programId, isProgram) + +export { useOrderIdForRun, useOrderIdForProgram } +export type { OrderIdResolution } diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 648668726a..c01ddc55cc 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -115,6 +115,20 @@ export const PROGRAM_VIEW = "/dashboard/program/[id]" export const programView = (id: number) => generatePath(PROGRAM_VIEW, { id: String(id) }) +export const RECEIPT_VIEW = "/receipt/[orderId]" +export const receiptView = (orderId: number) => + generatePath(RECEIPT_VIEW, { orderId: String(orderId) }) +/** + * Enrollments carry no order reference, so these routes resolve the order from the + * run/program before redirecting to `RECEIPT_VIEW`. + */ +export const RECEIPT_BY_RUN_VIEW = "/receipt/by-run/[runId]" +export const receiptByRunView = (runId: number) => + generatePath(RECEIPT_BY_RUN_VIEW, { runId: String(runId) }) +export const RECEIPT_BY_PROGRAM_VIEW = "/receipt/by-program/[programId]" +export const receiptByProgramView = (programId: number) => + generatePath(RECEIPT_BY_PROGRAM_VIEW, { programId: String(programId) }) + export const SEARCH = "/search" export const ABOUT = "/about"