richie-education 3.5.1 → 3.5.2-dev8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/js/api/joanie.ts +5 -0
  2. package/js/api/lms/dummy.ts +1 -0
  3. package/js/api/lms/openedx-fonzie-keycloak.spec.ts +7 -10
  4. package/js/api/lms/openedx-fonzie-keycloak.ts +24 -21
  5. package/js/components/SaleTunnel/index.spec.tsx +31 -1
  6. package/js/hooks/useLearnerCoursesSearch/index.tsx +17 -4
  7. package/js/hooks/useProductOrder/index.spec.tsx +1 -0
  8. package/js/pages/DashboardCourses/index.spec.tsx +40 -8
  9. package/js/types/Joanie.ts +18 -1
  10. package/js/utils/OrderHelper/index.spec.ts +75 -0
  11. package/js/utils/OrderHelper/index.ts +14 -2
  12. package/js/utils/test/factories/joanie.ts +10 -0
  13. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +213 -1
  14. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.tsx +133 -31
  15. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +22 -0
  16. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +29 -13
  17. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +0 -2
  18. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.spec.tsx +12 -0
  19. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +11 -0
  20. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +5 -1
  21. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.spec.tsx +12 -0
  22. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx +11 -0
  23. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal/index.spec.tsx +145 -0
  24. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal/index.tsx +259 -0
  25. package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.spec.tsx +102 -0
  26. package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.tsx +84 -2
  27. package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +9 -1
  28. package/js/widgets/Dashboard/index.spec.tsx +0 -1
  29. package/package.json +1 -1
package/js/api/joanie.ts CHANGED
@@ -101,6 +101,7 @@ export const getRoutes = () => {
101
101
  submit_for_signature: `${baseUrl}/orders/:id/submit_for_signature/`,
102
102
  submit_installment_payment: `${baseUrl}/orders/:id/submit-installment-payment/`,
103
103
  set_payment_method: `${baseUrl}/orders/:id/payment-method/`,
104
+ withdraw: `${baseUrl}/orders/:id/withdraw/`,
104
105
  },
105
106
  batchOrders: {
106
107
  get: `${baseUrl}/batch-orders/:id/`,
@@ -340,6 +341,10 @@ const API = (): Joanie.API => {
340
341
  method: 'POST',
341
342
  body: JSON.stringify(payload),
342
343
  }).then(checkStatus),
344
+ withdraw: async (id) =>
345
+ fetchWithJWT(ROUTES.user.orders.withdraw.replace(':id', id), {
346
+ method: 'POST',
347
+ }).then(checkStatus),
343
348
  },
344
349
  batchOrders: {
345
350
  create: async (payload) =>
@@ -65,6 +65,7 @@ function getUserInfo(username: DevDemoUser): User {
65
65
  access_token: accessToken,
66
66
  username: JWTPayload.username,
67
67
  full_name: JWTPayload.full_name,
68
+ email: JWTPayload.email,
68
69
  };
69
70
  }
70
71
 
@@ -31,19 +31,16 @@ describe('Fonzie Keycloak API', () => {
31
31
  jest.clearAllMocks();
32
32
  });
33
33
 
34
- it('uses its own route to get user information and enriches with keycloak account', async () => {
34
+ it('reads the email and full name from the access token claims', async () => {
35
+ // JWT payload: { email: 'john.doe@example.com', full_name: 'John Doe' }
36
+ const accessToken =
37
+ 'header.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiZnVsbF9uYW1lIjoiSm9obiBEb2UifQ.signature';
35
38
  const user = {
36
39
  username: 'test-richie-ncl',
37
- full_name: 'n c',
38
- };
39
- const keycloakAccount = {
40
- firstName: 'John',
41
- lastName: 'Doe',
42
- email: 'john.doe@example.com',
40
+ access_token: accessToken,
43
41
  };
44
42
 
45
43
  fetchMock.get('https://demo.endpoint.api/api/v1.0/user/me', user);
46
- fetchMock.get('https://keycloak.test/auth/realms/richie-realm/account/', keycloakAccount);
47
44
 
48
45
  const api = FonzieKeycloakAPIInterface(configuration);
49
46
  await expect(api.user.me()).resolves.toEqual({
@@ -53,14 +50,14 @@ describe('Fonzie Keycloak API', () => {
53
50
  });
54
51
  });
55
52
 
56
- it('falls back to fonzie data when keycloak account call fails', async () => {
53
+ it('does not fail when the access token has no usable claims', async () => {
57
54
  const user = {
58
55
  username: 'test-richie-ncl',
59
56
  full_name: 'n c',
57
+ access_token: 'not-a-jwt',
60
58
  };
61
59
 
62
60
  fetchMock.get('https://demo.endpoint.api/api/v1.0/user/me', user);
63
- fetchMock.get('https://keycloak.test/auth/realms/richie-realm/account/', 500);
64
61
 
65
62
  const api = FonzieKeycloakAPIInterface(configuration);
66
63
  await expect(api.user.me()).resolves.toEqual(user);
@@ -8,8 +8,25 @@ import { OpenEdxApiProfile } from 'types/openEdx';
8
8
  import { checkStatus } from 'api/utils';
9
9
  import { OpenEdxFullNameFormValues } from 'components/OpenEdxFullNameForm';
10
10
  import { location } from 'utils/indirection/window';
11
+ import { base64Decode } from 'utils/base64Parser';
12
+ import { Maybe } from 'types/utils';
11
13
  import OpenEdxHawthornApiInterface from './openedx-hawthorn';
12
14
 
15
+ /**
16
+ * Extract claims from the JWT issued by Fonzie. The `user/me` route does not expose
17
+ * the email nor an up to date full name, but the token it returns always carries them.
18
+ */
19
+ const getTokenClaims = (token: Maybe<string>): Maybe<{ email?: string; full_name?: string }> => {
20
+ if (!token) return undefined;
21
+
22
+ try {
23
+ const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
24
+ return JSON.parse(base64Decode(payload));
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ };
29
+
13
30
  /**
14
31
  *
15
32
  * OpenEdX completed by Fonzie API Implementation
@@ -46,27 +63,13 @@ const API = (APIConf: AuthenticationBackend): APILms => {
46
63
  me: async () => {
47
64
  const user = await ApiInterface.user.me();
48
65
  if (!user) return null;
49
- try {
50
- const keycloakAccount = await fetch(APIOptions.routes.user.account, {
51
- credentials: 'include',
52
- headers: {
53
- Accept: 'application/json',
54
- Authorization: `Bearer ${user.access_token}`,
55
- },
56
- }).then((res) => {
57
- if (!res.ok) throw new Error(`Keycloak account fetch failed: ${res.status}`);
58
- return res.json();
59
- });
60
- return {
61
- ...user,
62
- full_name:
63
- [keycloakAccount.firstName, keycloakAccount.lastName].filter(Boolean).join(' ') ||
64
- user.full_name,
65
- email: keycloakAccount.email || user.email,
66
- };
67
- } catch {
68
- return user;
69
- }
66
+
67
+ const claims = getTokenClaims(user.access_token);
68
+ return {
69
+ ...user,
70
+ full_name: user.full_name || claims?.full_name,
71
+ email: user.email ?? claims?.email,
72
+ };
70
73
  },
71
74
  login: () => {
72
75
  const next = encodeURIComponent(location.href);
@@ -552,7 +552,7 @@ describe.each([
552
552
  enrollmentDiscounted.offerings[0].product = product;
553
553
  fetchMock
554
554
  .get(
555
- `https://joanie.endpoint/api/v1.0/orders/?enrollment_id=${enrollmentDiscounted.id}&product_id=${product.id}&state=pending&state=pending_payment&state=no_payment&state=failed_payment&state=completed&state=draft&state=assigned&state=to_sign&state=signing&state=to_save_payment_method`,
555
+ `https://joanie.endpoint/api/v1.0/orders/?enrollment_id=${enrollmentDiscounted.id}&product_id=${product.id}&state=pending&state=pending_payment&state=no_payment&state=failed_payment&state=completed&state=pending_withdraw&state=draft&state=assigned&state=to_sign&state=signing&state=to_save_payment_method`,
556
556
  {
557
557
  results: [],
558
558
  next: null,
@@ -706,6 +706,36 @@ describe.each([
706
706
  screen.getByTestId('walkthrough-banner');
707
707
  });
708
708
 
709
+ it('should render the account name and email from the OpenEdx profile', async () => {
710
+ const product = ProductFactory().one();
711
+ const paymentPlan = PaymentPlanFactory().one();
712
+ fetchMock
713
+ .get(
714
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
715
+ [],
716
+ )
717
+ .get(
718
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
719
+ paymentPlan,
720
+ )
721
+ .get(
722
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
723
+ {},
724
+ );
725
+
726
+ render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
727
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
728
+ });
729
+
730
+ screen.getByRole('heading', { level: 4, name: 'Account email' });
731
+
732
+ await screen.findByText(openApiEdxProfile.email);
733
+
734
+ screen.getByText(
735
+ 'This email will be used to send you confirmation mails, it is the one you created your account with.',
736
+ );
737
+ });
738
+
709
739
  it('should show a checkbox to waive withdrawal right if the product is not withdrawable', async () => {
710
740
  const product = ProductFactory().one();
711
741
  const paymentPlan = PaymentPlanFactory().one();
@@ -1,8 +1,18 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useSearchParams } from 'react-router';
3
- import { Enrollment, CredentialOrder, ProductType, CANCELED_ORDER_STATES } from 'types/Joanie';
3
+ import { Enrollment, CredentialOrder, ProductType, OrderState } from 'types/Joanie';
4
4
  import { Maybe, Nullable } from 'types/utils';
5
- import { useOrdersEnrollments } from 'pages/DashboardCourses/useOrdersEnrollments';
5
+ import { OrderHelper } from 'utils/OrderHelper';
6
+ import { isOrder, useOrdersEnrollments } from 'pages/DashboardCourses/useOrdersEnrollments';
7
+
8
+ /**
9
+ * Orders canceled following the buyer's withdrawal must stay visible in the dashboard
10
+ * (the learner needs to keep a trace of that legal action), unlike other cancellation
11
+ * causes. The API can't filter on that distinction yet, so we only exclude REFUNDING/
12
+ * REFUNDED server-side and drop the remaining non-withdrawn CANCELED orders here.
13
+ */
14
+ const isHiddenCanceledOrder = (item: CredentialOrder | Enrollment) =>
15
+ isOrder(item) && OrderHelper.isCanceled(item) && !OrderHelper.isWithdrawn(item);
6
16
 
7
17
  const useLearnerCoursesSearch = () => {
8
18
  const [searchParams, setSearchParams] = useSearchParams();
@@ -23,7 +33,7 @@ const useLearnerCoursesSearch = () => {
23
33
  query,
24
34
  orderFilters: {
25
35
  product_type: [ProductType.CREDENTIAL],
26
- state_exclude: CANCELED_ORDER_STATES,
36
+ state_exclude: [OrderState.REFUNDING, OrderState.REFUNDED],
27
37
  },
28
38
  });
29
39
 
@@ -41,7 +51,10 @@ const useLearnerCoursesSearch = () => {
41
51
  }
42
52
 
43
53
  if (isNewSearchLoading || data.length > orderAndEnrollmentList?.length) {
44
- setOrderAndEnrollmentList(data as (CredentialOrder | Enrollment)[]);
54
+ const visibleData = (data as (CredentialOrder | Enrollment)[]).filter(
55
+ (item) => !isHiddenCanceledOrder(item),
56
+ );
57
+ setOrderAndEnrollmentList(visibleData);
45
58
  setCount(currentCount);
46
59
  }
47
60
  }, [data.length, isLoading, isNewSearchLoading, query]);
@@ -84,6 +84,7 @@ describe('useProductOrder', () => {
84
84
  `&state=${OrderState.NO_PAYMENT}` +
85
85
  `&state=${OrderState.FAILED_PAYMENT}` +
86
86
  `&state=${OrderState.COMPLETED}` +
87
+ `&state=${OrderState.PENDING_WITHDRAW}` +
87
88
  `&state=${OrderState.DRAFT}` +
88
89
  `&state=${OrderState.ASSIGNED}` +
89
90
  `&state=${OrderState.TO_SIGN}` +
@@ -10,7 +10,7 @@ import {
10
10
  CredentialOrderFactory,
11
11
  } from 'utils/test/factories/joanie';
12
12
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
13
- import { CourseLight, Offering, Enrollment, CredentialOrder } from 'types/Joanie';
13
+ import { CourseLight, Offering, Enrollment, CredentialOrder, OrderState } from 'types/Joanie';
14
14
  import { expectNoSpinner, expectSpinner } from 'utils/test/expectSpinner';
15
15
  import { expectBannerError, expectBannerInfo, expectNoBannerInfo } from 'utils/test/expectBanner';
16
16
  import { Deferred } from 'utils/test/deferred';
@@ -105,7 +105,6 @@ describe('<DashboardCourses/>', () => {
105
105
  fetchMock.get(
106
106
  'https://joanie.endpoint/api/v1.0/orders/' +
107
107
  '?product_type=credential' +
108
- '&state_exclude=canceled' +
109
108
  '&state_exclude=refunding' +
110
109
  '&state_exclude=refunded' +
111
110
  '&page=1' +
@@ -153,7 +152,6 @@ describe('<DashboardCourses/>', () => {
153
152
  fetchMock.get(
154
153
  'https://joanie.endpoint/api/v1.0/orders/' +
155
154
  '?product_type=credential' +
156
- '&state_exclude=canceled' +
157
155
  '&state_exclude=refunding' +
158
156
  '&state_exclude=refunded' +
159
157
  '&page=1' +
@@ -163,7 +161,6 @@ describe('<DashboardCourses/>', () => {
163
161
  next:
164
162
  'https://joanie.endpoint/api/v1.0/orders/' +
165
163
  '?product_type=credential' +
166
- '&state_exclude=canceled' +
167
164
  '&state_exclude=refunding' +
168
165
  '&state_exclude=refunded' +
169
166
  '&page=2' +
@@ -175,7 +172,6 @@ describe('<DashboardCourses/>', () => {
175
172
  fetchMock.get(
176
173
  'https://joanie.endpoint/api/v1.0/orders/' +
177
174
  '?product_type=credential' +
178
- '&state_exclude=canceled' +
179
175
  '&state_exclude=refunding' +
180
176
  '&state_exclude=refunded' +
181
177
  '&page=2' +
@@ -185,7 +181,6 @@ describe('<DashboardCourses/>', () => {
185
181
  next:
186
182
  'https://joanie.endpoint/api/v1.0/orders/' +
187
183
  '?product_type=credential' +
188
- '&state_exclude=canceled' +
189
184
  '&state_exclude=refunding' +
190
185
  '&state_exclude=refunded' +
191
186
  '&page=3' +
@@ -197,7 +192,6 @@ describe('<DashboardCourses/>', () => {
197
192
  fetchMock.get(
198
193
  'https://joanie.endpoint/api/v1.0/orders/' +
199
194
  '?product_type=credential' +
200
- '&state_exclude=canceled' +
201
195
  '&state_exclude=refunding' +
202
196
  '&state_exclude=refunded' +
203
197
  '&page=3' +
@@ -271,13 +265,51 @@ describe('<DashboardCourses/>', () => {
271
265
  expect(loadMoreButton).toBeEnabled();
272
266
  }, 15000);
273
267
 
268
+ it('keeps a withdrawn order visible while filtering out other canceled orders', async () => {
269
+ const activeOrder = CredentialOrderFactory({
270
+ state: OrderState.COMPLETED,
271
+ created_on: '2026-01-03T00:00:00.000Z',
272
+ }).one();
273
+ const withdrawnOrder = CredentialOrderFactory({
274
+ state: OrderState.CANCELED,
275
+ created_on: '2026-01-02T00:00:00.000Z',
276
+ withdrawn_confirmation_at: '2026-01-02T00:00:00.000Z',
277
+ }).one();
278
+ const canceledOrder = CredentialOrderFactory({
279
+ state: OrderState.CANCELED,
280
+ created_on: '2026-01-01T00:00:00.000Z',
281
+ withdrawn_confirmation_at: null,
282
+ }).one();
283
+ const { orders, offerings } = mockOrders([activeOrder, withdrawnOrder, canceledOrder]);
284
+
285
+ fetchMock.get(
286
+ 'https://joanie.endpoint/api/v1.0/orders/' +
287
+ '?product_type=credential' +
288
+ '&state_exclude=refunding' +
289
+ '&state_exclude=refunded' +
290
+ '&page=1' +
291
+ `&page_size=${perPage}`,
292
+ { results: orders, next: null, previous: null, count: orders.length },
293
+ );
294
+ fetchMock.get(
295
+ `https://joanie.endpoint/api/v1.0/enrollments/?was_created_by_order=false&is_active=true&page=1&page_size=${perPage}`,
296
+ { results: [], next: null, previous: null, count: 0 },
297
+ );
298
+
299
+ render(<DashboardTest initialRoute={LearnerDashboardPaths.COURSES} />, {
300
+ wrapper: BaseJoanieAppWrapper,
301
+ });
302
+
303
+ await expectNoSpinner('Loading orders and enrollments...');
304
+ await waitFor(() => expectList([activeOrder, withdrawnOrder], offerings));
305
+ });
306
+
274
307
  it('shows an error', async () => {
275
308
  jest.spyOn(console, 'error').mockImplementation(noop);
276
309
  const ordersDeferred = new Deferred();
277
310
  fetchMock.get(
278
311
  'https://joanie.endpoint/api/v1.0/orders/' +
279
312
  '?product_type=credential' +
280
- '&state_exclude=canceled' +
281
313
  '&state_exclude=refunding' +
282
314
  '&state_exclude=refunded' +
283
315
  '&page=1' +
@@ -312,6 +312,7 @@ export enum OrderState {
312
312
  NO_PAYMENT = 'no_payment',
313
313
  PENDING = 'pending',
314
314
  PENDING_PAYMENT = 'pending_payment',
315
+ PENDING_WITHDRAW = 'pending_withdraw',
315
316
  SIGNING = 'signing',
316
317
  TO_SAVE_PAYMENT_METHOD = 'to_save_payment_method',
317
318
  TO_SIGN = 'to_sign',
@@ -331,6 +332,7 @@ export const ACTIVE_ORDER_STATES = [
331
332
  OrderState.NO_PAYMENT,
332
333
  OrderState.FAILED_PAYMENT,
333
334
  OrderState.COMPLETED,
335
+ OrderState.PENDING_WITHDRAW,
334
336
  ];
335
337
 
336
338
  export const NOT_CANCELED_ORDER_STATES = [...ACTIVE_ORDER_STATES, ...PURCHASABLE_ORDER_STATES];
@@ -367,6 +369,11 @@ export interface Order {
367
369
  payment_schedule?: PaymentSchedule;
368
370
  credit_card_id?: CreditCard['id'];
369
371
  from_batch_order?: boolean;
372
+ has_waived_withdrawal_right: boolean;
373
+ eligible_to_withdraw: boolean;
374
+ withdrawal_date_limit: Nullable<string>;
375
+ withdrawn_requested_at: Nullable<string>;
376
+ withdrawn_confirmation_at: Nullable<string>;
370
377
  }
371
378
 
372
379
  export interface CredentialOrder extends Order {
@@ -418,7 +425,16 @@ export interface NestedCredentialOrder extends AbstractNestedOrder {
418
425
 
419
426
  export type OrderEnrollment = Pick<
420
427
  Order,
421
- 'id' | 'state' | 'product_id' | 'certificate_id' | 'payment_schedule'
428
+ | 'id'
429
+ | 'state'
430
+ | 'product_id'
431
+ | 'certificate_id'
432
+ | 'payment_schedule'
433
+ | 'has_waived_withdrawal_right'
434
+ | 'eligible_to_withdraw'
435
+ | 'withdrawal_date_limit'
436
+ | 'withdrawn_requested_at'
437
+ | 'withdrawn_confirmation_at'
422
438
  >;
423
439
 
424
440
  export interface NestedCourseOrder {
@@ -838,6 +854,7 @@ interface APIUser {
838
854
  payload?: OrderSubmitInstallmentPayment,
839
855
  ): Promise<Payment>;
840
856
  set_payment_method(payload: OrderSetPaymentMethodPayload): Promise<void>;
857
+ withdraw(id: Order['id']): Promise<CredentialOrder | CertificateOrder>;
841
858
  };
842
859
  batchOrders: {
843
860
  create(payload: BatchOrder): Promise<BatchOrderRead>;
@@ -0,0 +1,75 @@
1
+ import { OrderState } from 'types/Joanie';
2
+ import { CredentialOrderFactory, OrderEnrollmentFactory } from 'utils/test/factories/joanie';
3
+ import { OrderHelper, OrderStatus } from '.';
4
+
5
+ describe('OrderHelper', () => {
6
+ describe('isWithdrawn', () => {
7
+ it('should return false when the order is undefined', () => {
8
+ expect(OrderHelper.isWithdrawn(undefined)).toBe(false);
9
+ });
10
+
11
+ it('should return false when withdrawn_confirmation_at is not set', () => {
12
+ const order = CredentialOrderFactory({ withdrawn_confirmation_at: null }).one();
13
+ expect(OrderHelper.isWithdrawn(order)).toBe(false);
14
+ });
15
+
16
+ it('should return true when withdrawn_confirmation_at is set', () => {
17
+ const order = CredentialOrderFactory({
18
+ withdrawn_confirmation_at: new Date().toISOString(),
19
+ }).one();
20
+ expect(OrderHelper.isWithdrawn(order)).toBe(true);
21
+ });
22
+ });
23
+
24
+ describe('getState', () => {
25
+ it('should return WITHDRAWN when the order has been withdrawn, regardless of its state', () => {
26
+ const order = CredentialOrderFactory({
27
+ state: OrderState.CANCELED,
28
+ withdrawn_confirmation_at: new Date().toISOString(),
29
+ }).one();
30
+ expect(OrderHelper.getState(order)).toBe(OrderStatus.WITHDRAWN);
31
+ });
32
+
33
+ it('should return PENDING_WITHDRAWAL for a pending_withdraw order', () => {
34
+ const order = CredentialOrderFactory({
35
+ state: OrderState.PENDING_WITHDRAW,
36
+ withdrawn_confirmation_at: null,
37
+ }).one();
38
+ expect(OrderHelper.getState(order)).toBe(OrderStatus.PENDING_WITHDRAWAL);
39
+ });
40
+ });
41
+
42
+ describe('getActiveEnrollmentOrder', () => {
43
+ it('should return a withdrawn order matching the product id even though it is not active', () => {
44
+ const withdrawnOrder = OrderEnrollmentFactory({
45
+ state: OrderState.CANCELED,
46
+ product_id: 'PRODUCT_ID',
47
+ withdrawn_confirmation_at: new Date().toISOString(),
48
+ }).one();
49
+
50
+ expect(OrderHelper.getActiveEnrollmentOrder([withdrawnOrder], 'PRODUCT_ID')).toBe(
51
+ withdrawnOrder,
52
+ );
53
+ });
54
+
55
+ it('should not return a canceled order that has not been withdrawn', () => {
56
+ const canceledOrder = OrderEnrollmentFactory({
57
+ state: OrderState.CANCELED,
58
+ product_id: 'PRODUCT_ID',
59
+ withdrawn_confirmation_at: null,
60
+ }).one();
61
+
62
+ expect(OrderHelper.getActiveEnrollmentOrder([canceledOrder], 'PRODUCT_ID')).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe('isPurchasable', () => {
67
+ it('should return true when the order has been withdrawn, regardless of its state', () => {
68
+ const order = CredentialOrderFactory({
69
+ state: OrderState.CANCELED,
70
+ withdrawn_confirmation_at: new Date().toISOString(),
71
+ }).one();
72
+ expect(OrderHelper.isPurchasable(order)).toBe(true);
73
+ });
74
+ });
75
+ });
@@ -20,9 +20,11 @@ export enum OrderStatus {
20
20
  PASSED = 'passed',
21
21
  PENDING = 'pending',
22
22
  PENDING_PAYMENT = 'pending_payment',
23
+ PENDING_WITHDRAWAL = 'pending_withdrawal',
23
24
  WAITING_COUNTER_SIGNATURE = 'waiting_counter_signature',
24
25
  WAITING_PAYMENT_METHOD = 'waiting_payment_method',
25
26
  WAITING_SIGNATURE = 'waiting_signature',
27
+ WITHDRAWN = 'withdrawn',
26
28
  }
27
29
 
28
30
  /**
@@ -36,6 +38,9 @@ export class OrderHelper {
36
38
  if (order.state === OrderState.COMPLETED && order.certificate_id) {
37
39
  return OrderStatus.PASSED;
38
40
  }
41
+ if (OrderHelper.isWithdrawn(order)) {
42
+ return OrderStatus.WITHDRAWN;
43
+ }
39
44
 
40
45
  const orderStatusMap = {
41
46
  [OrderState.ASSIGNED]: OrderStatus.ASSIGNED,
@@ -48,6 +53,7 @@ export class OrderHelper {
48
53
  [OrderState.NO_PAYMENT]: OrderStatus.NO_PAYMENT,
49
54
  [OrderState.PENDING]: OrderStatus.PENDING,
50
55
  [OrderState.PENDING_PAYMENT]: OrderStatus.PENDING_PAYMENT,
56
+ [OrderState.PENDING_WITHDRAW]: OrderStatus.PENDING_WITHDRAWAL,
51
57
  [OrderState.SIGNING]: OrderStatus.WAITING_SIGNATURE,
52
58
  [OrderState.TO_SAVE_PAYMENT_METHOD]: OrderStatus.WAITING_PAYMENT_METHOD,
53
59
  [OrderState.TO_SIGN]: OrderStatus.WAITING_SIGNATURE,
@@ -65,7 +71,8 @@ export class OrderHelper {
65
71
  */
66
72
  static getActiveEnrollmentOrder(orders: OrderEnrollment[], productId: string) {
67
73
  const filter = (order: OrderEnrollment) =>
68
- ACTIVE_ORDER_STATES.includes(order.state) && order.product_id === productId;
74
+ (ACTIVE_ORDER_STATES.includes(order.state) || OrderHelper.isWithdrawn(order)) &&
75
+ order.product_id === productId;
69
76
  return orders.find(filter);
70
77
  }
71
78
 
@@ -100,8 +107,13 @@ export class OrderHelper {
100
107
  return CANCELED_ORDER_STATES.includes(order.state);
101
108
  }
102
109
 
110
+ static isWithdrawn(order?: Order | NestedCourseOrder | OrderEnrollment) {
111
+ if (!order) return false;
112
+ return Boolean('withdrawn_confirmation_at' in order && order.withdrawn_confirmation_at);
113
+ }
114
+
103
115
  static isPurchasable(order?: Order | NestedCourseOrder | OrderEnrollment) {
104
- if (!order) return true;
116
+ if (!order || OrderHelper.isWithdrawn(order)) return true;
105
117
  return PURCHASABLE_ORDER_STATES.includes(order.state);
106
118
  }
107
119
 
@@ -471,6 +471,11 @@ export const OrderEnrollmentFactory = factory((): OrderEnrollment => {
471
471
  product_id: faker.string.uuid(),
472
472
  state: OrderState.COMPLETED,
473
473
  payment_schedule: PaymentInstallmentFactory().many(1),
474
+ has_waived_withdrawal_right: false,
475
+ eligible_to_withdraw: false,
476
+ withdrawal_date_limit: null,
477
+ withdrawn_requested_at: null,
478
+ withdrawn_confirmation_at: null,
474
479
  };
475
480
  });
476
481
 
@@ -600,6 +605,11 @@ const AbstractOrderFactory = factory((): Order => {
600
605
  course: null,
601
606
  organization_id: faker.string.uuid(),
602
607
  organization: OrganizationFactory().one(),
608
+ has_waived_withdrawal_right: false,
609
+ eligible_to_withdraw: false,
610
+ withdrawal_date_limit: null,
611
+ withdrawn_requested_at: null,
612
+ withdrawn_confirmation_at: null,
603
613
  };
604
614
  });
605
615