diff --git a/RELEASE.rst b/RELEASE.rst index 5ee0ffa6f0..135ddd745a 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,15 @@ Release Notes ============= +Version 0.77.8 +-------------- + +- Batch the PostHog view-event loader instead of querying per event (#3785) +- feat: Collect required compliance fields before any enrollment or checkout (#3766) +- receipt page (#3717) +- Allow for null course ids in webhook payload (#3776) +- make healthcheck resistant to k8 pod culling (#3760) + Version 0.77.7 (Released August 18, 2026) -------------- diff --git a/frontends/api/package.json b/frontends/api/package.json index 83612e3dac..391af5b986 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@mitodl/mit-learn-api-axios": "2026.7.22", - "@mitodl/mitxonline-api-axios": "2026.8.6", + "@mitodl/mitxonline-api-axios": "2026.8.18", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index 186b779090..1c4f7a43b6 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -595,13 +595,13 @@ export interface ContentFileWebHookRequest { * @type {string} * @memberof ContentFileWebHookRequest */ - course_id?: string + course_id?: string | null /** * * @type {string} * @memberof ContentFileWebHookRequest */ - course_readable_id?: string + course_readable_id?: string | null } /** @@ -627,13 +627,13 @@ export interface ContentFileWebHookRequestRequest { * @type {string} * @memberof ContentFileWebHookRequestRequest */ - course_id?: string + course_id?: string | null /** * * @type {string} * @memberof ContentFileWebHookRequestRequest */ - course_readable_id?: string + course_readable_id?: string | null } /** @@ -33656,8 +33656,8 @@ export const WebhooksApiAxiosParamCreator = function ( * @param {WebhooksContentFilesCreateSourceEnum} source * `mit_edx` - mit_edx * `mitpe` - mitpe * `mitxonline` - mitxonline * `oll` - oll * `ocw` - ocw * `podcast` - podcast * `mit_climate` - mit_climate * `see` - see * `xpro` - xpro * `youtube` - youtube * `canvas` - canvas * `ovs` - ovs * @param {ContentFileWebHookRequestRequest} ContentFileWebHookRequestRequest * @param {string} [content_path] - * @param {string} [course_id] - * @param {string} [course_readable_id] + * @param {string | null} [course_id] + * @param {string | null} [course_readable_id] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -33665,8 +33665,8 @@ export const WebhooksApiAxiosParamCreator = function ( source: WebhooksContentFilesCreateSourceEnum, ContentFileWebHookRequestRequest: ContentFileWebHookRequestRequest, content_path?: string, - course_id?: string, - course_readable_id?: string, + course_id?: string | null, + course_readable_id?: string | null, options: RawAxiosRequestConfig = {}, ): Promise => { // verify required parameter 'source' is not null or undefined @@ -33845,8 +33845,8 @@ export const WebhooksApiFp = function (configuration?: Configuration) { * @param {WebhooksContentFilesCreateSourceEnum} source * `mit_edx` - mit_edx * `mitpe` - mitpe * `mitxonline` - mitxonline * `oll` - oll * `ocw` - ocw * `podcast` - podcast * `mit_climate` - mit_climate * `see` - see * `xpro` - xpro * `youtube` - youtube * `canvas` - canvas * `ovs` - ovs * @param {ContentFileWebHookRequestRequest} ContentFileWebHookRequestRequest * @param {string} [content_path] - * @param {string} [course_id] - * @param {string} [course_readable_id] + * @param {string | null} [course_id] + * @param {string | null} [course_readable_id] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -33854,8 +33854,8 @@ export const WebhooksApiFp = function (configuration?: Configuration) { source: WebhooksContentFilesCreateSourceEnum, ContentFileWebHookRequestRequest: ContentFileWebHookRequestRequest, content_path?: string, - course_id?: string, - course_readable_id?: string, + course_id?: string | null, + course_readable_id?: string | null, options?: RawAxiosRequestConfig, ): Promise< ( @@ -34052,14 +34052,14 @@ export interface WebhooksApiWebhooksContentFilesCreateRequest { * @type {string} * @memberof WebhooksApiWebhooksContentFilesCreate */ - readonly course_id?: string + readonly course_id?: string | null /** * * @type {string} * @memberof WebhooksApiWebhooksContentFilesCreate */ - readonly course_readable_id?: string + readonly course_readable_id?: string | null } /** diff --git a/frontends/api/src/mitxonline/hooks/orders/queries.ts b/frontends/api/src/mitxonline/hooks/orders/queries.ts index 87304be885..351e65bdbb 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,24 @@ 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`. + * + * `opts` is required rather than defaulted: older deployments drop the + * pagination envelope when `limit` is absent and return a bare array, which + * would not match the declared return type. + */ + 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..e9ee249935 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/orders.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/orders.ts @@ -1,5 +1,17 @@ import { faker } from "@faker-js/faker/locale/en" -import type { Order, TransactionLine } from "@mitodl/mitxonline-api-axios/v2" +import type { + Line, + Nested, + Order, + OrderHistory, + OrderRefundsInner, + OrderStreetAddress, + OrderTransactions, + PaginatedOrderHistoryList, + Product, + RedeemedDiscount, + TransactionLine, +} from "@mitodl/mitxonline-api-axios/v2" const transactionLine = ( overrides: Partial = {}, @@ -17,19 +29,135 @@ 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 orderRefund = ( + overrides: Partial = {}, +): OrderRefundsInner => ({ + amount: Number(faker.commerce.price({ min: 5, max: 500 })), + date: faker.date.past().toISOString(), + ...overrides, +}) + const order = (overrides: Partial = {}): Order => ({ id: faker.number.int(), state: "fulfilled", - purchaser: [], + purchaser: { + country: "US", + email: faker.internet.email(), + }, total_price_paid: faker.commerce.price({ min: 50, max: 500 }), lines: [transactionLine()], discounts: [], refunds: [], + refund_eligible: false, reference_number: faker.string.alphanumeric(10), created_on: faker.date.past().toISOString(), - transactions: {}, - street_address: {}, + transactions: orderTransactions(), + street_address: orderStreetAddress(), ...overrides, }) -export { order, transactionLine } +/** + * 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(), + refund_eligible: false, + ...overrides, +}) + +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, + orderRefund, + orderStreetAddress, + orderTransactions, + line, + product, + redeemedDiscount, + transactionLine, +} diff --git a/frontends/api/src/mitxonline/test-utils/factories/user.ts b/frontends/api/src/mitxonline/test-utils/factories/user.ts index 4e3614374a..3b16027837 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/user.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/user.ts @@ -12,7 +12,13 @@ const enforcerId = new UniqueEnforcer() const legalAddress = (): LegalAddress => ({ country: faker.location.countryCode(), - state: faker.datatype.boolean() ? faker.location.state() : null, + // Real values are ISO-3166-2 codes (e.g. "US-MA"), not the full names + // faker.location.state() returns, and only a handful of countries even + // have subdivisions. A random name for a random country is never a value + // a state