richie-education 3.3.1 → 3.3.2-dev5

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.
package/js/api/joanie.ts CHANGED
@@ -173,6 +173,9 @@ export const getRoutes = () => {
173
173
  paymentPlan: {
174
174
  get: `${baseUrl}/courses/:course_id/products/:id/payment-plan/`,
175
175
  },
176
+ deepLink: {
177
+ get: `${baseUrl}/courses/:course_id/products/:id/deep-link/`,
178
+ },
176
179
  },
177
180
  orders: {
178
181
  get: `${baseUrl}/courses/:course_id/orders/:id/`,
@@ -585,6 +588,23 @@ const API = (): Joanie.API => {
585
588
  );
586
589
  },
587
590
  },
591
+ deepLink: {
592
+ get: async (
593
+ filters?: Joanie.CourseProductQueryFilters,
594
+ ): Promise<Joanie.OfferingDeepLink> => {
595
+ if (!filters) {
596
+ throw new Error('A course code and a product id are required to fetch a deep link');
597
+ } else if (!filters.course_id) {
598
+ throw new Error('A course code is required to fetch a deep link');
599
+ } else if (!filters.id) {
600
+ throw new Error('A product id is required to fetch a deep link');
601
+ }
602
+
603
+ return fetchWithJWT(buildApiUrl(ROUTES.courses.products.deepLink.get, filters)).then(
604
+ checkStatus,
605
+ );
606
+ },
607
+ },
588
608
  },
589
609
  orders: {
590
610
  get: async (filters?: Joanie.CourseOrderResourceQuery) => {
@@ -170,7 +170,7 @@ describe('OpenEdX Hawthorn API', () => {
170
170
  );
171
171
  });
172
172
 
173
- it('throws HttpError.localizedMessage on enrollment failure', async () => {
173
+ it('throws HttpError.localizedMessage on enrollment failure for bad requests', async () => {
174
174
  fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, {
175
175
  status: HttpStatusCode.BAD_REQUEST,
176
176
  body: { localizedMessage: 'You are not authorized to enroll in this course' },
@@ -189,7 +189,7 @@ describe('OpenEdX Hawthorn API', () => {
189
189
  );
190
190
  });
191
191
 
192
- it('throws HttpError on enrollment failure when localizedMessage property is not present in the payload', async () => {
192
+ it('throws HttpError on enrollment failure when localizedMessage property is not present in the payload for bad requests', async () => {
193
193
  fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, {
194
194
  status: HttpStatusCode.BAD_REQUEST,
195
195
  body: { message: 'Bad Request' },
@@ -202,6 +202,38 @@ describe('OpenEdX Hawthorn API', () => {
202
202
  ).rejects.toThrow(new HttpError(HttpStatusCode.BAD_REQUEST, 'Bad Request'));
203
203
  });
204
204
 
205
+ it('throws HttpError.localizedMessage on enrollment failure for forbidden requests', async () => {
206
+ fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, {
207
+ status: HttpStatusCode.FORBIDDEN,
208
+ body: { localizedMessage: 'You are not authorized to enroll in this course' },
209
+ });
210
+
211
+ await expect(
212
+ HawthornApi.enrollment.set(`https://demo.endpoint/courses?course_id=${courseId}`, {
213
+ username,
214
+ }),
215
+ ).rejects.toThrow(
216
+ new HttpError(
217
+ HttpStatusCode.FORBIDDEN,
218
+ 'Forbidden',
219
+ 'You are not authorized to enroll in this course',
220
+ ),
221
+ );
222
+ });
223
+
224
+ it('throws HttpError on enrollment failure when localizedMessage property is not present in the payload for forbidden requests', async () => {
225
+ fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, {
226
+ status: HttpStatusCode.FORBIDDEN,
227
+ body: { message: 'Forbidden' },
228
+ });
229
+
230
+ await expect(
231
+ HawthornApi.enrollment.set(`https://demo.endpoint/courses?course_id=${courseId}`, {
232
+ username,
233
+ }),
234
+ ).rejects.toThrow(new HttpError(HttpStatusCode.FORBIDDEN, 'Forbidden'));
235
+ });
236
+
205
237
  it('throws HttpError when response has no json payload', async () => {
206
238
  fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, HttpStatusCode.BAD_REQUEST);
207
239
 
@@ -115,7 +115,10 @@ const API = (APIConf: AuthenticationBackend | LMSBackend, options?: APIOptions):
115
115
  })
116
116
  .then(async (response) => {
117
117
  if (response.ok) return response.json();
118
- if (response.status === HttpStatusCode.BAD_REQUEST) {
118
+ if (
119
+ response.status === HttpStatusCode.BAD_REQUEST ||
120
+ response.status === HttpStatusCode.FORBIDDEN
121
+ ) {
119
122
  if (response.headers.get('Content-Type') === 'application/json') {
120
123
  const { localizedMessage } = await response.json();
121
124
  throw new HttpError(response.status, response.statusText, localizedMessage);
@@ -115,6 +115,10 @@ describe('PurchaseButton', () => {
115
115
  .get(
116
116
  `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
117
117
  [],
118
+ )
119
+ .get(
120
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/deep-link/`,
121
+ {},
118
122
  );
119
123
 
120
124
  render(
@@ -159,6 +163,10 @@ describe('PurchaseButton', () => {
159
163
  .get(
160
164
  `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
161
165
  [],
166
+ )
167
+ .get(
168
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/deep-link/`,
169
+ {},
162
170
  );
163
171
  render(
164
172
  <Wrapper client={createTestQueryClient({ user })}>
@@ -201,6 +209,10 @@ describe('PurchaseButton', () => {
201
209
  .get(
202
210
  `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
203
211
  [],
212
+ )
213
+ .get(
214
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/deep-link/`,
215
+ {},
204
216
  );
205
217
  delete product.remaining_order_count;
206
218
 
@@ -9,6 +9,7 @@ import {
9
9
  SaleTunnelStep,
10
10
  SaleTunnelContext,
11
11
  SaleTunnelContextType,
12
+ PaymentMode,
12
13
  } from 'components/SaleTunnel/GenericSaleTunnel';
13
14
  import { Address, PaymentSchedule } from 'types/Joanie';
14
15
  import {
@@ -74,6 +75,8 @@ describe('AddressSelector', () => {
74
75
  setSchedule,
75
76
  needsPayment: false,
76
77
  setNeedsPayment: jest.fn(),
78
+ paymentMode: PaymentMode.CLASSIC,
79
+ setPaymentMode: jest.fn(),
77
80
  }),
78
81
  [billingAddress, voucherCode, schedule],
79
82
  );
@@ -65,6 +65,8 @@ export interface SaleTunnelContextType {
65
65
  setSchedule: (schedule?: PaymentSchedule) => void;
66
66
  needsPayment: boolean;
67
67
  setNeedsPayment: (needsPayment: boolean) => void;
68
+ paymentMode: PaymentMode;
69
+ setPaymentMode: (mode: PaymentMode) => void;
68
70
  }
69
71
 
70
72
  export const SaleTunnelContext = createContext<SaleTunnelContextType>({} as any);
@@ -86,6 +88,11 @@ export enum SaleTunnelStep {
86
88
  SUCCESS,
87
89
  }
88
90
 
91
+ export enum PaymentMode {
92
+ CLASSIC = 'classic',
93
+ CPF = 'cpf',
94
+ }
95
+
89
96
  interface GenericSaleTunnelProps extends SaleTunnelProps {
90
97
  eventKey: string;
91
98
 
@@ -111,6 +118,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
111
118
  );
112
119
  const [voucherCode, setVoucherCode] = useState<string>();
113
120
  const [needsPayment, setNeedsPayment] = useState(true);
121
+ const [paymentMode, setPaymentMode] = useState<PaymentMode>(PaymentMode.CLASSIC);
114
122
 
115
123
  const nextStep = useCallback(() => {
116
124
  if (order)
@@ -184,6 +192,8 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
184
192
  setSchedule,
185
193
  needsPayment,
186
194
  setNeedsPayment,
195
+ paymentMode,
196
+ setPaymentMode,
187
197
  }),
188
198
  [
189
199
  props,
@@ -197,6 +207,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
197
207
  hasWaivedWithdrawalRight,
198
208
  voucherCode,
199
209
  needsPayment,
210
+ paymentMode,
200
211
  ],
201
212
  );
202
213
 
@@ -1,15 +1,16 @@
1
1
  import { ChangeEvent, useEffect, useState } from 'react';
2
2
  import { defineMessages, FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
3
- import { Alert, Button, Input, VariantType } from '@openfun/cunningham-react';
3
+ import { Alert, Button, Input, Radio, RadioGroup, VariantType } from '@openfun/cunningham-react';
4
4
  import { AddressSelector } from 'components/SaleTunnel/AddressSelector';
5
5
  import { PaymentScheduleGrid } from 'components/PaymentScheduleGrid';
6
- import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
6
+ import { PaymentMode, useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
7
7
  import OpenEdxFullNameForm from 'components/OpenEdxFullNameForm';
8
8
  import { useSession } from 'contexts/SessionContext';
9
9
  import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
10
10
  import WithdrawRightCheckbox from 'components/SaleTunnel/WithdrawRightCheckbox';
11
11
  import { PaymentSchedule, ProductType } from 'types/Joanie';
12
12
  import { usePaymentPlan } from 'hooks/usePaymentPlan';
13
+ import { useDeepLink } from 'hooks/useDeepLink';
13
14
  import { HttpError } from 'utils/errors/HttpError';
14
15
  import { APIBackend, KeycloakAccountApi } from 'types/api';
15
16
  import context from 'utils/context';
@@ -123,6 +124,32 @@ const messages = defineMessages({
123
124
  description: 'Message displayed when the order is part of a batch order',
124
125
  defaultMessage: 'No billing information required. This order is covered by your organization.',
125
126
  },
127
+ paymentModeTitle: {
128
+ id: 'components.SaleTunnel.Information.paymentMode.title',
129
+ description: 'Title for the payment mode selection section',
130
+ defaultMessage: 'Payment method',
131
+ },
132
+ paymentModeClassic: {
133
+ id: 'components.SaleTunnel.Information.paymentMode.classic',
134
+ description: 'Label for the classic card payment option',
135
+ defaultMessage: 'Credit card payment',
136
+ },
137
+ paymentModeCpf: {
138
+ id: 'components.SaleTunnel.Information.paymentMode.cpf',
139
+ description: 'Label for the CPF (Mon Compte Formation) payment option',
140
+ defaultMessage: 'My Training Account (CPF)',
141
+ },
142
+ cpfDescription: {
143
+ id: 'components.SaleTunnel.Information.cpf.description',
144
+ description: 'Explanatory text for the CPF payment option',
145
+ defaultMessage:
146
+ 'Pay for your training using your personal training account (CPF) on Mon Compte Formation.',
147
+ },
148
+ cpfButtonLabel: {
149
+ id: 'components.SaleTunnel.Information.cpf.buttonLabel',
150
+ description: 'Label for the button redirecting to Mon Compte Formation',
151
+ defaultMessage: 'Go to Mon Compte Formation',
152
+ },
126
153
  });
127
154
 
128
155
  export const SaleTunnelInformationSingular = () => {
@@ -134,6 +161,9 @@ export const SaleTunnelInformationSingular = () => {
134
161
  setSchedule,
135
162
  needsPayment,
136
163
  setNeedsPayment,
164
+ setHasWaivedWithdrawalRight,
165
+ paymentMode,
166
+ setPaymentMode,
137
167
  } = useSaleTunnelContext();
138
168
  const [voucherError, setVoucherError] = useState<HttpError | null>(null);
139
169
  const query = usePaymentPlan({
@@ -141,11 +171,17 @@ export const SaleTunnelInformationSingular = () => {
141
171
  product_id: props.product.id,
142
172
  ...(voucherCode ? { voucher_code: voucherCode } : {}),
143
173
  });
174
+ const deepLinkQuery = useDeepLink({
175
+ course_code: props.course?.code ?? props.enrollment!.course_run.course.code,
176
+ product_id: props.product.id,
177
+ });
144
178
  const schedule = query.data?.payment_schedule ?? props.paymentPlan?.payment_schedule;
145
179
  const price = query.data?.price ?? props.paymentPlan?.price;
146
180
  const discountedPrice = query.data?.discounted_price ?? props.paymentPlan?.discounted_price;
147
181
  const discount = query.data?.discount ?? props.paymentPlan?.discount;
148
- const fromBatchOrder = query.data?.from_batch_order ?? props.paymentPlan?.from_batch_order;
182
+ const skipContractInputs =
183
+ query.data?.skip_contract_inputs ?? props.paymentPlan?.skip_contract_inputs;
184
+ const deepLink = deepLinkQuery.data?.deep_link;
149
185
 
150
186
  const isCredentialWithPrice =
151
187
  product.type === ProductType.CREDENTIAL &&
@@ -165,68 +201,99 @@ export const SaleTunnelInformationSingular = () => {
165
201
  }, [query.error, voucherCode, setVoucherCode]);
166
202
 
167
203
  useEffect(() => {
168
- setNeedsPayment(!fromBatchOrder);
169
- }, [fromBatchOrder, setNeedsPayment]);
204
+ setNeedsPayment(!skipContractInputs);
205
+ if (skipContractInputs) {
206
+ setHasWaivedWithdrawalRight(false);
207
+ }
208
+ }, [skipContractInputs, setNeedsPayment, setHasWaivedWithdrawalRight]);
170
209
 
210
+ const intl = useIntl();
171
211
  const isKeycloakBackend = [APIBackend.KEYCLOAK, APIBackend.FONZIE_KEYCLOAK].includes(
172
212
  context?.authentication.backend as APIBackend,
173
213
  );
174
214
 
175
215
  return (
176
216
  <>
177
- {needsPayment && (
178
- <div>
217
+ {deepLink && (
218
+ <div className="mb-s">
179
219
  <h3 className="block-title mb-t">
180
- <FormattedMessage {...messages.title} />
220
+ <FormattedMessage {...messages.paymentModeTitle} />
181
221
  </h3>
182
- <div className="description mb-s">
183
- <FormattedMessage {...messages.description} />
184
- </div>
185
- {isKeycloakBackend ? (
186
- <KeycloakAccountEdit />
187
- ) : (
188
- <>
189
- <OpenEdxFullNameForm />
190
- <div className="mt-s">
191
- <Email />
192
- </div>
193
- </>
194
- )}
195
- <AddressSelector />
196
- </div>
197
- )}
198
- {!needsPayment && (
199
- <div>
200
- <h3 className="block-title">
201
- <FormattedMessage {...messages.title} />
202
- </h3>
203
- <Alert type={VariantType.NEUTRAL}>
204
- <FormattedMessage {...messages.noBillingInformation} />
205
- </Alert>
222
+ <RadioGroup>
223
+ <Radio
224
+ label={intl.formatMessage(messages.paymentModeClassic)}
225
+ value={PaymentMode.CLASSIC}
226
+ checked={paymentMode === PaymentMode.CLASSIC}
227
+ onChange={() => setPaymentMode(PaymentMode.CLASSIC)}
228
+ />
229
+ <Radio
230
+ label={intl.formatMessage(messages.paymentModeCpf)}
231
+ value={PaymentMode.CPF}
232
+ checked={paymentMode === PaymentMode.CPF}
233
+ onChange={() => setPaymentMode(PaymentMode.CPF)}
234
+ />
235
+ </RadioGroup>
206
236
  </div>
207
237
  )}
208
- <div>
209
- {isCredentialWithPrice &&
210
- (schedule ? (
211
- <PaymentScheduleBlock schedule={schedule!} />
212
- ) : (
238
+ {paymentMode === PaymentMode.CPF ? (
239
+ <CpfPayment deepLink={deepLink!} />
240
+ ) : (
241
+ <>
242
+ {needsPayment && (
213
243
  <div>
214
- <h4 className="block-title">
215
- <FormattedMessage {...messages.paymentSchedule} />
216
- </h4>
244
+ <h3 className="block-title mb-t">
245
+ <FormattedMessage {...messages.title} />
246
+ </h3>
247
+ <div className="description mb-s">
248
+ <FormattedMessage {...messages.description} />
249
+ </div>
250
+ {isKeycloakBackend ? (
251
+ <KeycloakAccountEdit />
252
+ ) : (
253
+ <>
254
+ <OpenEdxFullNameForm />
255
+ <div className="mt-s">
256
+ <Email />
257
+ </div>
258
+ </>
259
+ )}
260
+ <AddressSelector />
261
+ </div>
262
+ )}
263
+ {!needsPayment && (
264
+ <div>
265
+ <h3 className="block-title">
266
+ <FormattedMessage {...messages.title} />
267
+ </h3>
217
268
  <Alert type={VariantType.NEUTRAL}>
218
- <FormattedMessage {...messages.noPaymentSchedule} />
269
+ <FormattedMessage {...messages.noBillingInformation} />
219
270
  </Alert>
220
271
  </div>
221
- ))}
222
- <Voucher
223
- discount={discount}
224
- voucherError={voucherError}
225
- setVoucherError={setVoucherError}
226
- />
227
- <Total price={price} discountedPrice={discountedPrice} />
228
- {needsPayment && <WithdrawRightCheckbox />}
229
- </div>
272
+ )}
273
+ <div>
274
+ {isCredentialWithPrice &&
275
+ (schedule ? (
276
+ <PaymentScheduleBlock schedule={schedule!} />
277
+ ) : (
278
+ <div>
279
+ <h4 className="block-title">
280
+ <FormattedMessage {...messages.paymentSchedule} />
281
+ </h4>
282
+ <Alert type={VariantType.NEUTRAL}>
283
+ <FormattedMessage {...messages.noPaymentSchedule} />
284
+ </Alert>
285
+ </div>
286
+ ))}
287
+ <Voucher
288
+ discount={discount}
289
+ voucherError={voucherError}
290
+ setVoucherError={setVoucherError}
291
+ />
292
+ <Total price={price} discountedPrice={discountedPrice} />
293
+ {needsPayment && <WithdrawRightCheckbox />}
294
+ </div>
295
+ </>
296
+ )}
230
297
  </>
231
298
  );
232
299
  };
@@ -425,3 +492,22 @@ const PaymentScheduleBlock = ({ schedule }: { schedule: PaymentSchedule }) => {
425
492
  </div>
426
493
  );
427
494
  };
495
+
496
+ const CpfPayment = ({ deepLink }: { deepLink: string }) => {
497
+ return (
498
+ <div className="sale-tunnel__cpf">
499
+ <p className="description mb-s">
500
+ <FormattedMessage {...messages.cpfDescription} />
501
+ </p>
502
+ <Button
503
+ color="primary"
504
+ fullWidth={true}
505
+ href={deepLink}
506
+ target="_blank"
507
+ rel="noopener noreferrer"
508
+ >
509
+ <FormattedMessage {...messages.cpfButtonLabel} />
510
+ </Button>
511
+ </div>
512
+ );
513
+ };
@@ -1,7 +1,7 @@
1
1
  import { useState } from 'react';
2
2
  import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
3
3
  import { Select } from '@openfun/cunningham-react';
4
- import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
4
+ import { useSaleTunnelContext, PaymentMode } from 'components/SaleTunnel/GenericSaleTunnel';
5
5
  import { SaleTunnelInformationSingular } from 'components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationSingular';
6
6
  import { SaleTunnelInformationGroup } from 'components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup';
7
7
  import { ProductType } from 'types/Joanie';
@@ -36,7 +36,7 @@ export enum FormType {
36
36
 
37
37
  export const SaleTunnelInformation = () => {
38
38
  const intl = useIntl();
39
- const { setBatchOrder, setSchedule, product } = useSaleTunnelContext();
39
+ const { setBatchOrder, setSchedule, setPaymentMode, product } = useSaleTunnelContext();
40
40
  const productType = product.type;
41
41
  const options = [
42
42
  { label: intl.formatMessage(messages.purchaseTypeOptionSingle), value: FormType.SINGULAR },
@@ -63,6 +63,7 @@ export const SaleTunnelInformation = () => {
63
63
  setPurchaseType(e.target.value as FormType);
64
64
  setBatchOrder(undefined);
65
65
  setSchedule(undefined);
66
+ setPaymentMode(PaymentMode.CLASSIC);
66
67
  }}
67
68
  />
68
69
  </div>
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
2
  import { Alert, Button, VariantType } from '@openfun/cunningham-react';
3
3
  import { defineMessages, FormattedMessage } from 'react-intl';
4
- import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
4
+ import { PaymentMode, useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
5
5
  import { validationSchema } from 'components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup';
6
6
  import { useOrders } from 'hooks/useOrders';
7
7
  import { useBatchOrder } from 'hooks/useBatchOrder';
@@ -107,7 +107,12 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
107
107
  props: saleTunnelProps,
108
108
  voucherCode,
109
109
  needsPayment,
110
+ paymentMode,
110
111
  } = useSaleTunnelContext();
112
+
113
+ if (paymentMode === PaymentMode.CPF) {
114
+ return null;
115
+ }
111
116
  const { methods: orderMethods } = useOrders(undefined, { enabled: false });
112
117
  const { methods: batchOrderMethods } = useBatchOrder();
113
118
  const [state, setState] = useState<ComponentStates>(ComponentStates.IDLE);
@@ -1,5 +1,6 @@
1
1
  import fetchMock from 'fetch-mock';
2
2
  import { screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
3
4
  import queryString from 'query-string';
4
5
  import {
5
6
  RichieContextFactory as mockRichieContextFactory,
@@ -99,7 +100,11 @@ describe('SaleTunnel / Credential', () => {
99
100
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
100
101
  .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
101
102
  overwriteRoutes: true,
102
- });
103
+ })
104
+ .get(
105
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
106
+ {},
107
+ );
103
108
 
104
109
  render(<Wrapper product={product} course={course} />, {
105
110
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
@@ -115,4 +120,106 @@ describe('SaleTunnel / Credential', () => {
115
120
  // - Payment button should not be disabled.
116
121
  expect($button.disabled).toBe(false);
117
122
  });
123
+
124
+ it('should display CPF payment option and redirect to deepLink when deepLink is available', async () => {
125
+ const course = PacedCourseFactory().one();
126
+ const product = CredentialProductFactory().one();
127
+ const billingAddress: Joanie.Address = AddressFactory({ is_main: true }).one();
128
+ const deepLink = 'https://placeholder.com/course/1';
129
+ const orderQueryParameters = {
130
+ course_code: course.code,
131
+ product_id: product.id,
132
+ state: NOT_CANCELED_ORDER_STATES,
133
+ };
134
+
135
+ fetchMock
136
+ .get(
137
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
138
+ [],
139
+ )
140
+ .get(
141
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
142
+ [],
143
+ )
144
+ .get(
145
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
146
+ { deep_link: deepLink },
147
+ )
148
+ .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
149
+ overwriteRoutes: true,
150
+ });
151
+
152
+ window.open = jest.fn();
153
+ const user = userEvent.setup({ delay: null });
154
+
155
+ render(<Wrapper product={product} course={course} />, {
156
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
157
+ });
158
+
159
+ await screen.findByRole('heading', { level: 3, name: /payment method/i });
160
+
161
+ // - By default, credit card payment should be selected.
162
+ expect(screen.getByRole('radio', { name: /credit card payment/i })).toBeChecked();
163
+ expect(screen.getByRole('radio', { name: /my training account \(cpf\)/i })).not.toBeChecked();
164
+
165
+ await user.click(screen.getByRole('radio', { name: /my training account \(cpf\)/i }));
166
+
167
+ // - CPF description and redirect button should be visible.
168
+ expect(
169
+ screen.getByText(/pay for your training using your personal training account/i),
170
+ ).toBeInTheDocument();
171
+ const cpfButton = screen.getByRole('link', { name: /go to mon compte formation/i });
172
+
173
+ await user.click(cpfButton);
174
+ });
175
+
176
+ it('should not display CPF payment option when deepLink is null', async () => {
177
+ const course = PacedCourseFactory().one();
178
+ const product = CredentialProductFactory().one();
179
+ const billingAddress: Joanie.Address = AddressFactory({ is_main: true }).one();
180
+ const orderQueryParameters = {
181
+ course_code: course.code,
182
+ product_id: product.id,
183
+ state: NOT_CANCELED_ORDER_STATES,
184
+ };
185
+
186
+ fetchMock
187
+ .get(
188
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
189
+ [],
190
+ )
191
+ .get(
192
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
193
+ [],
194
+ )
195
+ .get(
196
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
197
+ { deep_link: null },
198
+ )
199
+ .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
200
+ overwriteRoutes: true,
201
+ });
202
+
203
+ render(<Wrapper product={product} course={course} />, {
204
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
205
+ });
206
+
207
+ // - wait for address to be loaded.
208
+ await screen.findByText(getAddressLabel(billingAddress));
209
+
210
+ // - Payment method section and CPF option should not be rendered.
211
+ expect(
212
+ screen.queryByRole('heading', { level: 3, name: /payment method/i }),
213
+ ).not.toBeInTheDocument();
214
+ expect(
215
+ screen.queryByRole('radio', { name: /my training account \(cpf\)/i }),
216
+ ).not.toBeInTheDocument();
217
+ expect(screen.queryByRole('radio', { name: /credit card payment/i })).not.toBeInTheDocument();
218
+ expect(
219
+ screen.queryByRole('link', { name: /go to mon compte formation/i }),
220
+ ).not.toBeInTheDocument();
221
+
222
+ // - Classic billing information section should be displayed.
223
+ expect(screen.getByText(/this information will be used for billing/i)).toBeInTheDocument();
224
+ });
118
225
  });
@@ -66,6 +66,10 @@ const setupBatchOrderMocks = (params: {
66
66
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
67
67
  paymentPlan,
68
68
  );
69
+ fetchMock.get(
70
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
71
+ {},
72
+ );
69
73
  fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
70
74
  fetchMock.get(
71
75
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify({
@@ -365,7 +369,7 @@ describe('SaleTunnel', () => {
365
369
  discounted_price: 0,
366
370
  discount: '-100%',
367
371
  payment_schedule: undefined,
368
- from_batch_order: true,
372
+ skip_contract_inputs: true,
369
373
  }).one();
370
374
  fetchMock.get(
371
375
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT100`,
@@ -116,6 +116,10 @@ describe('SaleTunnel', () => {
116
116
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
117
117
  paymentPlan,
118
118
  );
119
+ fetchMock.get(
120
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
121
+ {},
122
+ );
119
123
  fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
120
124
  const orderQueryParameters = {
121
125
  product_id: product.id,
@@ -281,6 +285,11 @@ describe('SaleTunnel', () => {
281
285
  paymentPlanVoucher,
282
286
  { overwriteRoutes: true },
283
287
  );
288
+ fetchMock.get(
289
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
290
+ {},
291
+ { overwriteRoutes: true },
292
+ );
284
293
  await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT30');
285
294
  await user.click(screen.getByRole('button', { name: 'Validate' }));
286
295
  screen.getByRole('heading', { name: 'Payment schedule' });
@@ -175,6 +175,10 @@ describe.each([
175
175
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
176
176
  paymentPlan,
177
177
  )
178
+ .get(
179
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
180
+ {},
181
+ )
178
182
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
179
183
  .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, order)
180
184
  .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
@@ -188,6 +192,7 @@ describe.each([
188
192
  nbApiCalls += 1; // get user account call.
189
193
  nbApiCalls += 1; // get user preferences call.
190
194
  nbApiCalls += 1; // product payment-schedule call
195
+ nbApiCalls += 1; // product deep-link call
191
196
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
192
197
 
193
198
  const user = userEvent.setup({ delay: null });
@@ -263,6 +268,10 @@ describe.each([
263
268
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
264
269
  paymentPlan,
265
270
  )
271
+ .get(
272
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
273
+ {},
274
+ )
266
275
  .post('https://joanie.endpoint/api/v1.0/orders/', deferred.promise)
267
276
  .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
268
277
  overwriteRoutes: true,
@@ -275,6 +284,7 @@ describe.each([
275
284
  nbApiCalls += 1; // get user account call.
276
285
  nbApiCalls += 1; // get user preferences call.
277
286
  nbApiCalls += 1; // get paymentPlan call.
287
+ nbApiCalls += 1; // get deep-link call.
278
288
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
279
289
 
280
290
  const user = userEvent.setup({ delay: null });
@@ -333,6 +343,10 @@ describe.each([
333
343
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
334
344
  paymentPlan,
335
345
  )
346
+ .get(
347
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
348
+ {},
349
+ )
336
350
  .get('https://joanie.endpoint/api/v1.0/offerings/get-organizations/', []);
337
351
 
338
352
  render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
@@ -379,6 +393,10 @@ describe.each([
379
393
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
380
394
  paymentPlan,
381
395
  )
396
+ .get(
397
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
398
+ {},
399
+ )
382
400
  .get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
383
401
  overwriteRoutes: true,
384
402
  })
@@ -390,7 +408,7 @@ describe.each([
390
408
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
391
409
  });
392
410
 
393
- nbApiCalls += 3;
411
+ nbApiCalls += 4;
394
412
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
395
413
 
396
414
  await screen.findByTestId('sale-tunnel-save-payment-method-step');
@@ -415,6 +433,10 @@ describe.each([
415
433
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
416
434
  paymentPlan,
417
435
  )
436
+ .get(
437
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
438
+ {},
439
+ )
418
440
  .get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
419
441
  overwriteRoutes: true,
420
442
  })
@@ -426,7 +448,7 @@ describe.each([
426
448
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
427
449
  });
428
450
 
429
- nbApiCalls += 3;
451
+ nbApiCalls += 4;
430
452
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
431
453
 
432
454
  await screen.findByTestId('sale-tunnel-sign-step');
@@ -446,6 +468,10 @@ describe.each([
446
468
  .get(
447
469
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
448
470
  paymentPlan,
471
+ )
472
+ .get(
473
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
474
+ {},
449
475
  );
450
476
 
451
477
  render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
@@ -537,6 +563,10 @@ describe.each([
537
563
  .get(
538
564
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
539
565
  paymentPlan,
566
+ )
567
+ .get(
568
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
569
+ {},
540
570
  );
541
571
  render(
542
572
  <Wrapper
@@ -585,6 +615,10 @@ describe.each([
585
615
  .get(
586
616
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
587
617
  paymentPlan,
618
+ )
619
+ .get(
620
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
621
+ {},
588
622
  );
589
623
 
590
624
  render(
@@ -659,6 +693,10 @@ describe.each([
659
693
  .get(
660
694
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
661
695
  paymentPlan,
696
+ )
697
+ .get(
698
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
699
+ {},
662
700
  );
663
701
 
664
702
  render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
@@ -679,6 +717,10 @@ describe.each([
679
717
  .get(
680
718
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
681
719
  paymentPlan,
720
+ )
721
+ .get(
722
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
723
+ {},
682
724
  );
683
725
 
684
726
  render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={false} />, {
@@ -699,6 +741,10 @@ describe.each([
699
741
  .get(
700
742
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
701
743
  paymentPlan,
744
+ )
745
+ .get(
746
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
747
+ {},
702
748
  );
703
749
 
704
750
  render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
@@ -719,6 +765,10 @@ describe.each([
719
765
  .get(
720
766
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
721
767
  paymentPlan,
768
+ )
769
+ .get(
770
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
771
+ {},
722
772
  );
723
773
 
724
774
  render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={false} />, {
@@ -762,6 +812,10 @@ describe.each([
762
812
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
763
813
  paymentPlan,
764
814
  )
815
+ .get(
816
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
817
+ {},
818
+ )
765
819
  .get(
766
820
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT30`,
767
821
  {
@@ -807,7 +861,7 @@ describe.each([
807
861
  const paymentPlanVoucher = PaymentPlanFactory({
808
862
  discounted_price: 0.0,
809
863
  discount: '-100%',
810
- from_batch_order: true,
864
+ skip_contract_inputs: true,
811
865
  }).one();
812
866
  const product = ProductFactory().one();
813
867
  fetchMock
@@ -819,6 +873,10 @@ describe.each([
819
873
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
820
874
  paymentPlan,
821
875
  )
876
+ .get(
877
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
878
+ {},
879
+ )
822
880
  .get(
823
881
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT100`,
824
882
  paymentPlanVoucher,
@@ -871,6 +929,10 @@ describe.each([
871
929
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
872
930
  paymentPlan,
873
931
  )
932
+ .get(
933
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
934
+ {},
935
+ )
874
936
  .get(
875
937
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT30`,
876
938
  paymentPlanVoucher,
@@ -1000,6 +1062,10 @@ describe('SaleTunnel with Keycloak backend', () => {
1000
1062
  .get(
1001
1063
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
1002
1064
  paymentPlan,
1065
+ )
1066
+ .get(
1067
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
1068
+ {},
1003
1069
  );
1004
1070
 
1005
1071
  render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
@@ -1054,6 +1120,10 @@ describe('SaleTunnel with Keycloak backend', () => {
1054
1120
  .get(
1055
1121
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
1056
1122
  paymentPlan,
1123
+ )
1124
+ .get(
1125
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
1126
+ {},
1057
1127
  );
1058
1128
 
1059
1129
  render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
@@ -0,0 +1,21 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
3
+ import { OfferingDeepLink } from 'types/Joanie';
4
+ import { HttpError } from 'utils/errors/HttpError';
5
+
6
+ type DeepLinkFilters = {
7
+ course_code: string;
8
+ product_id: string;
9
+ };
10
+
11
+ export const useDeepLink = (filters: DeepLinkFilters) => {
12
+ const api = useJoanieApi();
13
+ return useQuery<OfferingDeepLink, HttpError>({
14
+ queryKey: ['courses-products', ...Object.values(filters), 'deep-link'],
15
+ queryFn: () =>
16
+ api.courses.products.deepLink.get({
17
+ id: filters.product_id,
18
+ course_id: filters.course_code,
19
+ }),
20
+ });
21
+ };
@@ -221,6 +221,10 @@ export interface Offering extends OfferingLight {
221
221
  is_withdrawable: boolean;
222
222
  rules?: OfferingRule;
223
223
  }
224
+
225
+ export interface OfferingDeepLink {
226
+ deep_link: Nullable<string>;
227
+ }
224
228
  export function isOffering(
225
229
  entity: CourseListItem | OfferingLight | RichieCourse,
226
230
  ): entity is OfferingLight {
@@ -657,7 +661,7 @@ export interface PaymentPlan {
657
661
  discount?: string;
658
662
  discounted_price?: number;
659
663
  payment_schedule: PaymentSchedule;
660
- from_batch_order: boolean;
664
+ skip_contract_inputs: boolean;
661
665
  }
662
666
 
663
667
  // - API
@@ -891,6 +895,9 @@ export interface API {
891
895
  paymentPlan: {
892
896
  get(filters?: CourseProductQueryFilters): Promise<Nullable<PaymentPlan>>;
893
897
  };
898
+ deepLink: {
899
+ get(filters?: CourseProductQueryFilters): Promise<OfferingDeepLink>;
900
+ };
894
901
  };
895
902
  orders: {
896
903
  get(
@@ -51,7 +51,11 @@ import { Payment, PaymentMethod, PaymentProviders } from 'components/PaymentInte
51
51
  import { CourseStateFactory } from 'utils/test/factories/richie';
52
52
  import { FactoryHelper } from 'utils/test/factories/helper';
53
53
  import { JoanieUserApiAbilityActions, JoanieUserProfile } from 'types/User';
54
- import { SaleTunnelContextType, SaleTunnelStep } from 'components/SaleTunnel/GenericSaleTunnel';
54
+ import {
55
+ SaleTunnelContextType,
56
+ SaleTunnelStep,
57
+ PaymentMode,
58
+ } from 'components/SaleTunnel/GenericSaleTunnel';
55
59
  import { SaleTunnelProps } from 'components/SaleTunnel';
56
60
  import { noop } from 'utils/index';
57
61
  import { factory } from './factories';
@@ -453,7 +457,7 @@ export const PaymentPlanFactory = factory((): PaymentPlan => {
453
457
  price: faker.number.int({ min: 1, max: 1000, multipleOf: 10 }),
454
458
  discount: undefined,
455
459
  discounted_price: undefined,
456
- from_batch_order: false,
460
+ skip_contract_inputs: false,
457
461
  };
458
462
  });
459
463
 
@@ -670,5 +674,7 @@ export const SaleTunnelContextFactory = factory(
670
674
  setSchedule: noop,
671
675
  needsPayment: true,
672
676
  setNeedsPayment: noop,
677
+ paymentMode: PaymentMode.CLASSIC,
678
+ setPaymentMode: noop,
673
679
  }),
674
680
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "3.3.1",
3
+ "version": "3.3.2-dev5",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {