richie-education 3.2.1-dev1 → 3.2.1-dev11

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
@@ -16,7 +16,6 @@ import { JOANIE_API_VERSION } from 'settings';
16
16
  import { ResourcesQuery } from 'hooks/useResources';
17
17
  import { ObjectHelper } from 'utils/ObjectHelper';
18
18
  import { Maybe, Nullable } from 'types/utils';
19
- import { PaymentSchedule } from 'types/Joanie';
20
19
  import { checkStatus, getFileFromResponse } from './utils';
21
20
 
22
21
  /*
@@ -145,8 +144,8 @@ export const getRoutes = () => {
145
144
  },
146
145
  products: {
147
146
  get: `${baseUrl}/courses/:course_id/products/:id/`,
148
- paymentSchedule: {
149
- get: `${baseUrl}/courses/:course_id/products/:id/payment-schedule/`,
147
+ paymentPlan: {
148
+ get: `${baseUrl}/courses/:course_id/products/:id/payment-plan/`,
150
149
  },
151
150
  },
152
151
  orders: {
@@ -430,10 +429,10 @@ const API = (): Joanie.API => {
430
429
 
431
430
  return fetchWithJWT(buildApiUrl(ROUTES.courses.products.get, filters)).then(checkStatus);
432
431
  },
433
- paymentSchedule: {
432
+ paymentPlan: {
434
433
  get: async (
435
434
  filters?: Joanie.CourseProductQueryFilters,
436
- ): Promise<Nullable<PaymentSchedule>> => {
435
+ ): Promise<Nullable<Joanie.PaymentPlan>> => {
437
436
  if (!filters) {
438
437
  throw new Error(
439
438
  'A course code and a product id are required to fetch a course product',
@@ -444,9 +443,9 @@ const API = (): Joanie.API => {
444
443
  throw new Error('A product id is required to fetch a course product');
445
444
  }
446
445
 
447
- return fetchWithJWT(
448
- buildApiUrl(ROUTES.courses.products.paymentSchedule.get, filters),
449
- ).then(checkStatus);
446
+ return fetchWithJWT(buildApiUrl(ROUTES.courses.products.paymentPlan.get, filters)).then(
447
+ checkStatus,
448
+ );
450
449
  },
451
450
  },
452
451
  },
@@ -113,7 +113,7 @@ describe('PurchaseButton', () => {
113
113
  fetchMock
114
114
  .get(url, {})
115
115
  .get(
116
- `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-schedule/`,
116
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
117
117
  [],
118
118
  );
119
119
 
@@ -157,7 +157,7 @@ describe('PurchaseButton', () => {
157
157
  fetchMock
158
158
  .get(url, {})
159
159
  .get(
160
- `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-schedule/`,
160
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
161
161
  [],
162
162
  );
163
163
  render(
@@ -199,7 +199,7 @@ describe('PurchaseButton', () => {
199
199
  fetchMock
200
200
  .get(url, {})
201
201
  .get(
202
- `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-schedule/`,
202
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
203
203
  [],
204
204
  );
205
205
  delete product.remaining_order_count;
@@ -49,6 +49,7 @@ describe('AddressSelector', () => {
49
49
 
50
50
  const Wrapper = () => {
51
51
  const [billingAddress, setBillingAddress] = useState<Address>();
52
+ const [voucherCode, setVoucherCode] = useState<string>();
52
53
  const context: SaleTunnelContextType = useMemo(
53
54
  () => ({
54
55
  webAnalyticsEventKey: 'eventKey',
@@ -65,8 +66,9 @@ describe('AddressSelector', () => {
65
66
  nextStep: jest.fn(),
66
67
  hasWaivedWithdrawalRight: false,
67
68
  setHasWaivedWithdrawalRight: jest.fn(),
69
+ setVoucherCode,
68
70
  }),
69
- [billingAddress],
71
+ [billingAddress, voucherCode],
70
72
  );
71
73
  contextRef.current = context;
72
74
 
@@ -51,6 +51,8 @@ export interface SaleTunnelContextType {
51
51
  unregisterSubmitCallback: (key: string) => void;
52
52
  runSubmitCallbacks: () => Promise<void>;
53
53
  nextStep: () => void;
54
+ voucherCode?: string;
55
+ setVoucherCode: (code?: string) => void;
54
56
  }
55
57
 
56
58
  export const SaleTunnelContext = createContext<SaleTunnelContextType>({} as any);
@@ -93,6 +95,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
93
95
  const [submitCallbacks, setSubmitCallbacks] = useState<Map<string, () => Promise<void>>>(
94
96
  new Map(),
95
97
  );
98
+ const [voucherCode, setVoucherCode] = useState<string>();
96
99
 
97
100
  const nextStep = useCallback(() => {
98
101
  if (order)
@@ -102,6 +105,8 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
102
105
  setStep(SaleTunnelStep.SIGN);
103
106
  } else if (order.state === OrderState.TO_SAVE_PAYMENT_METHOD) {
104
107
  setStep(SaleTunnelStep.SAVE_PAYMENT);
108
+ } else if (order.state === OrderState.COMPLETED) {
109
+ setStep(SaleTunnelStep.SUCCESS);
105
110
  }
106
111
  break;
107
112
  case SaleTunnelStep.SIGN:
@@ -147,8 +152,19 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
147
152
  runSubmitCallbacks: async () => {
148
153
  await Promise.all(Array.from(submitCallbacks.values()).map((cb) => cb()));
149
154
  },
155
+ voucherCode,
156
+ setVoucherCode,
150
157
  }),
151
- [props, order, billingAddress, creditCard, step, submitCallbacks, hasWaivedWithdrawalRight],
158
+ [
159
+ props,
160
+ order,
161
+ billingAddress,
162
+ creditCard,
163
+ step,
164
+ submitCallbacks,
165
+ hasWaivedWithdrawalRight,
166
+ voucherCode,
167
+ ],
152
168
  );
153
169
 
154
170
  return (
@@ -1,14 +1,16 @@
1
- import { defineMessages, FormattedMessage, FormattedNumber } from 'react-intl';
1
+ import { ChangeEvent, useEffect, useState } from 'react';
2
+ import { defineMessages, FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
3
+ import { Alert, Button, Input, VariantType } from '@openfun/cunningham-react';
2
4
  import { AddressSelector } from 'components/SaleTunnel/AddressSelector';
3
5
  import { PaymentScheduleGrid } from 'components/PaymentScheduleGrid';
4
6
  import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
5
7
  import OpenEdxFullNameForm from 'components/OpenEdxFullNameForm';
6
8
  import { useSession } from 'contexts/SessionContext';
7
9
  import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
8
- import { usePaymentSchedule } from 'hooks/usePaymentSchedule';
9
- import { Spinner } from 'components/Spinner';
10
10
  import WithdrawRightCheckbox from 'components/SaleTunnel/WithdrawRightCheckbox';
11
- import { ProductType } from 'types/Joanie';
11
+ import { PaymentSchedule, ProductType } from 'types/Joanie';
12
+ import { usePaymentPlan } from 'hooks/usePaymentPlan';
13
+ import { HttpError } from 'utils/errors/HttpError';
12
14
 
13
15
  const messages = defineMessages({
14
16
  title: {
@@ -52,10 +54,68 @@ const messages = defineMessages({
52
54
  defaultMessage:
53
55
  'This email will be used to send you confirmation mails, it is the one you created your account with.',
54
56
  },
57
+ voucherTitle: {
58
+ id: 'components.SaleTunnel.Information.voucher.title',
59
+ description: 'Title for the voucher',
60
+ defaultMessage: 'Voucher code',
61
+ },
62
+ voucherInfo: {
63
+ id: 'components.SaleTunnel.Information.voucher.info',
64
+ description: 'Info for the voucher',
65
+ defaultMessage: 'If you have a voucher code, please enter it in the field below.',
66
+ },
67
+ voucherValidate: {
68
+ id: 'components.SaleTunnel.Information.voucher.validate',
69
+ description: 'Validate text for the voucher',
70
+ defaultMessage: 'Validate',
71
+ },
72
+ voucherDelete: {
73
+ id: 'components.SaleTunnel.Information.voucher.delete',
74
+ description: 'Delete text for the voucher',
75
+ defaultMessage: 'Delete this voucher',
76
+ },
77
+ voucherErrorInvalid: {
78
+ id: 'components.SaleTunnel.Information.voucher.errorInvalid',
79
+ description: 'Error when voucher is invalid',
80
+ defaultMessage: 'The submitted voucher code is not valid.',
81
+ },
82
+ voucherErrorTooManyRequests: {
83
+ id: 'components.SaleTunnel.Information.voucher.errorTooManyRequests',
84
+ description: 'Error when user has tried too many vouchers',
85
+ defaultMessage: 'Too many attempts. Please try again later.',
86
+ },
87
+ discount: {
88
+ id: 'components.SaleTunnel.Information.voucher.discount',
89
+ description: 'Discount description',
90
+ defaultMessage: 'Discount applied',
91
+ },
55
92
  });
56
93
 
57
94
  export const SaleTunnelInformation = () => {
58
- const { product } = useSaleTunnelContext();
95
+ const { props, product, voucherCode, setVoucherCode } = useSaleTunnelContext();
96
+ const [voucherError, setVoucherError] = useState<HttpError | null>(null);
97
+ const query = usePaymentPlan({
98
+ course_code: props.course?.code ?? props.enrollment!.course_run.course.code,
99
+ product_id: props.product.id,
100
+ ...(voucherCode ? { voucher_code: voucherCode } : {}),
101
+ });
102
+ const schedule = query.data?.payment_schedule ?? props.paymentPlan?.payment_schedule;
103
+ const price = query.data?.price ?? props.paymentPlan?.price;
104
+ const discountedPrice = query.data?.discounted_price ?? props.paymentPlan?.discounted_price;
105
+ const discount = query.data?.discount ?? props.paymentPlan?.discount;
106
+
107
+ const showPaymentSchedule =
108
+ product.type === ProductType.CREDENTIAL &&
109
+ schedule &&
110
+ (discountedPrice != null ? discountedPrice > 0 : price != null && price > 0);
111
+
112
+ useEffect(() => {
113
+ if (query.error && voucherCode) {
114
+ setVoucherCode('');
115
+ setVoucherError(query.error);
116
+ }
117
+ }, [query.error, voucherCode, setVoucherCode]);
118
+
59
119
  return (
60
120
  <div className="sale-tunnel__main__column sale-tunnel__information">
61
121
  <div>
@@ -72,13 +132,19 @@ export const SaleTunnelInformation = () => {
72
132
  </div>
73
133
  </div>
74
134
  <div>
75
- {product.type === ProductType.CREDENTIAL && <PaymentScheduleBlock />}
76
- <Total />
135
+ {showPaymentSchedule && <PaymentScheduleBlock schedule={schedule} />}
136
+ <Voucher
137
+ discount={discount}
138
+ voucherError={voucherError}
139
+ setVoucherError={setVoucherError}
140
+ />
141
+ <Total price={price} discountedPrice={discountedPrice} />
77
142
  <WithdrawRightCheckbox />
78
143
  </div>
79
144
  </div>
80
145
  );
81
146
  };
147
+
82
148
  const Email = () => {
83
149
  const { user } = useSession();
84
150
  const { data: openEdxProfileData } = useOpenEdxProfile({
@@ -100,44 +166,126 @@ const Email = () => {
100
166
  );
101
167
  };
102
168
 
103
- const Total = () => {
104
- const { product, offering, enrollment } = useSaleTunnelContext();
105
- const totalPrice =
106
- enrollment?.offerings?.[0]?.rules?.discounted_price ??
107
- offering?.rules?.discounted_price ??
108
- product.price;
169
+ const Total = ({ price, discountedPrice }: { price?: number; discountedPrice?: number }) => {
170
+ const { product } = useSaleTunnelContext();
171
+ const totalPrice = price || product.price;
109
172
  return (
110
173
  <div className="sale-tunnel__total">
111
174
  <div className="sale-tunnel__total__amount mt-t" data-testid="sale-tunnel__total__amount">
112
175
  <div className="block-title">
113
176
  <FormattedMessage {...messages.totalLabel} />
114
177
  </div>
178
+
115
179
  <div className="block-title">
116
- <FormattedNumber value={totalPrice} style="currency" currency={product.price_currency} />
180
+ {discountedPrice !== undefined ? (
181
+ <>
182
+ <span className="price--striked">
183
+ <FormattedNumber
184
+ value={totalPrice}
185
+ style="currency"
186
+ currency={product.price_currency}
187
+ />
188
+ </span>
189
+ <FormattedNumber
190
+ value={discountedPrice}
191
+ style="currency"
192
+ currency={product.price_currency}
193
+ />
194
+ </>
195
+ ) : (
196
+ <FormattedNumber
197
+ value={totalPrice}
198
+ style="currency"
199
+ currency={product.price_currency}
200
+ />
201
+ )}
117
202
  </div>
118
203
  </div>
119
204
  </div>
120
205
  );
121
206
  };
122
207
 
123
- const PaymentScheduleBlock = () => {
124
- const { props } = useSaleTunnelContext();
125
- const query = usePaymentSchedule({
126
- course_code: props.course?.code || props.enrollment!.course_run.course.code,
127
- product_id: props.product.id,
128
- });
208
+ const Voucher = ({
209
+ discount,
210
+ voucherError,
211
+ setVoucherError,
212
+ }: {
213
+ discount?: string;
214
+ voucherError: HttpError | null;
215
+ setVoucherError: (value: HttpError | null) => void;
216
+ }) => {
217
+ const intl = useIntl();
218
+ const { voucherCode, setVoucherCode } = useSaleTunnelContext();
219
+ const [voucher, setVoucher] = useState('');
220
+ const handleVoucher = (e: ChangeEvent<HTMLInputElement>) => setVoucher(e.target.value);
221
+ const submitVoucher = () => {
222
+ setVoucherError(null);
223
+ setVoucherCode(voucher);
224
+ setVoucher('');
225
+ };
129
226
 
130
- if (!query.data || query.isLoading) {
131
- return <Spinner size="large" />;
132
- }
227
+ return (
228
+ <div className="sale-tunnel__voucher">
229
+ <div className="description">
230
+ <h4 className="block-title mb-t">
231
+ <FormattedMessage {...messages.voucherTitle} />
232
+ </h4>
233
+ <span className="mb-t">
234
+ <FormattedMessage {...messages.voucherInfo} />
235
+ </span>
236
+ </div>
237
+ <div className="form">
238
+ <Input
239
+ className="form-field mt-s"
240
+ value={voucher}
241
+ onChange={handleVoucher}
242
+ label={intl.formatMessage(messages.voucherTitle)}
243
+ disabled={!!voucherCode}
244
+ />
245
+ <Button size="small" color="primary" onClick={submitVoucher} disabled={!!voucherCode}>
246
+ <FormattedMessage {...messages.voucherValidate} />
247
+ </Button>
248
+ </div>
249
+ {voucherCode && (
250
+ <div className="voucher-tag">
251
+ <span>{voucherCode}</span>
252
+ <button
253
+ onClick={() => setVoucherCode('')}
254
+ title={intl.formatMessage(messages.voucherDelete)}
255
+ >
256
+ <span className="material-icons">close</span>
257
+ </button>
258
+ </div>
259
+ )}
260
+ {discount && (
261
+ <div className="voucher-discount">
262
+ <span>
263
+ <FormattedMessage {...messages.discount} />
264
+ </span>
265
+ <span>{discount}</span>
266
+ </div>
267
+ )}
268
+ {voucherError && (
269
+ <Alert type={VariantType.ERROR} className="mt-s">
270
+ {voucherError.code === 429 ? (
271
+ <FormattedMessage {...messages.voucherErrorTooManyRequests} />
272
+ ) : (
273
+ <FormattedMessage {...messages.voucherErrorInvalid} />
274
+ )}
275
+ </Alert>
276
+ )}
277
+ </div>
278
+ );
279
+ };
133
280
 
281
+ const PaymentScheduleBlock = ({ schedule }: { schedule: PaymentSchedule }) => {
134
282
  return (
135
283
  <div className="payment-schedule">
136
284
  <h4 className="block-title mb-t">
137
285
  <FormattedMessage {...messages.paymentSchedule} />
138
286
  </h4>
139
287
  <div className="mt-t">
140
- <PaymentScheduleGrid schedule={query.data} />
288
+ <PaymentScheduleGrid schedule={schedule} />
141
289
  </div>
142
290
  </div>
143
291
  );
@@ -18,7 +18,7 @@ const messages = defineMessages({
18
18
  walkthroughToSignAndSavePayment: {
19
19
  id: 'components.SaleTunnel.SubscriptionButton.walkthroughToSignAndSavePayment',
20
20
  defaultMessage:
21
- 'To enroll in the training, you will first be invited to sign the training agreement and then to define a payment method.',
21
+ 'To enroll in the training, you will first be invited to sign the training agreement and then to define a payment method if required.',
22
22
  description:
23
23
  'Message explaining the subscription process with a training agreement to sign and a payment method to set.',
24
24
  },
@@ -72,7 +72,7 @@ interface Props {
72
72
  buildOrderPayload: (
73
73
  payload: Pick<
74
74
  OrderCreationPayload,
75
- 'product_id' | 'billing_address' | 'has_waived_withdrawal_right'
75
+ 'product_id' | 'billing_address' | 'has_waived_withdrawal_right' | 'voucher_code'
76
76
  >,
77
77
  ) => OrderCreationPayload;
78
78
  }
@@ -87,6 +87,7 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
87
87
  nextStep,
88
88
  runSubmitCallbacks,
89
89
  props: saleTunnelProps,
90
+ voucherCode,
90
91
  } = useSaleTunnelContext();
91
92
  const { methods: orderMethods } = useOrders(undefined, { enabled: false });
92
93
  const [state, setState] = useState<ComponentStates>(ComponentStates.IDLE);
@@ -125,6 +126,7 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
125
126
  product_id: product.id,
126
127
  billing_address: billingAddress!,
127
128
  has_waived_withdrawal_right: hasWaivedWithdrawalRight,
129
+ voucher_code: voucherCode,
128
130
  });
129
131
 
130
132
  orderMethods.create(payload, {
@@ -82,6 +82,54 @@
82
82
  font-size: 0.75rem;
83
83
  }
84
84
  }
85
+ .price--striked {
86
+ text-decoration: line-through;
87
+ opacity: 0.5;
88
+ margin-right: 0.5rem;
89
+ }
90
+ .sale-tunnel__voucher {
91
+ margin-top: 1rem;
92
+ & > .form {
93
+ display: flex;
94
+ flex-direction: row;
95
+ align-items: center;
96
+ margin-top: 0.75rem;
97
+ & > .mt-s {
98
+ margin-top: 0;
99
+ }
100
+ & > button {
101
+ margin-left: 0.5rem;
102
+ }
103
+ }
104
+ & > .voucher-tag {
105
+ margin-top: 0.5rem;
106
+ border-radius: 0.25rem;
107
+ padding: 0.5rem;
108
+ width: fit-content;
109
+ color: black;
110
+ display: flex;
111
+ flex-direction: row;
112
+ justify-content: space-between;
113
+ align-items: center;
114
+ border: none;
115
+ background-color: lightgray;
116
+ &:hover {
117
+ background-color: gray;
118
+ }
119
+ & > button {
120
+ background: none;
121
+ border: none;
122
+ display: flex;
123
+ align-items: center;
124
+ }
125
+ }
126
+ & > .voucher-discount {
127
+ margin-top: 1rem;
128
+ display: flex;
129
+ justify-content: space-between;
130
+ font-size: var(--c--theme--font--sizes--l);
131
+ }
132
+ }
85
133
  }
86
134
 
87
135
  .description {
@@ -93,7 +93,7 @@ describe('SaleTunnel / Credential', () => {
93
93
  fetchMock
94
94
  .get(url, [])
95
95
  .get(
96
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
96
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
97
97
  [],
98
98
  )
99
99
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
@@ -19,8 +19,8 @@ import {
19
19
  CredentialOrderFactory,
20
20
  CreditCardFactory,
21
21
  PaymentFactory,
22
- PaymentInstallmentFactory,
23
22
  ProductFactory,
23
+ PaymentPlanFactory,
24
24
  } from 'utils/test/factories/joanie';
25
25
  import { CourseRun, NOT_CANCELED_ORDER_STATES, OrderState } from 'types/Joanie';
26
26
  import { Priority } from 'types';
@@ -70,7 +70,9 @@ describe('SaleTunnel', () => {
70
70
  new Intl.NumberFormat('en', {
71
71
  currency,
72
72
  style: 'currency',
73
- }).format(price);
73
+ })
74
+ .format(price)
75
+ .replace(/(\u202F|\u00a0)/g, ' ');
74
76
 
75
77
  beforeEach(() => {
76
78
  richieUser = UserFactory().one();
@@ -104,15 +106,15 @@ describe('SaleTunnel', () => {
104
106
  product,
105
107
  is_withdrawable: false,
106
108
  }).one();
107
- const paymentSchedule = PaymentInstallmentFactory().many(2);
109
+ const paymentPlan = PaymentPlanFactory().one();
108
110
 
109
111
  fetchMock.get(
110
112
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/`,
111
113
  offering,
112
114
  );
113
115
  fetchMock.get(
114
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
115
- paymentSchedule,
116
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
117
+ paymentPlan,
116
118
  );
117
119
  fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
118
120
  const orderQueryParameters = {
@@ -135,7 +137,7 @@ describe('SaleTunnel', () => {
135
137
  screen.getByText(
136
138
  // the price formatter generates non-breaking spaces and getByText doesn't seem to handle that well, replace it
137
139
  // with a regular space. We replace NNBSP (\u202F) and NBSP (\u00a0) with a regular space
138
- priceFormatter(product.price_currency, product.price).replace(/(\u202F|\u00a0)/g, ' '),
140
+ priceFormatter(product.price_currency, product.price),
139
141
  );
140
142
  expect(screen.queryByText('Purchased')).not.toBeInTheDocument();
141
143
 
@@ -250,14 +252,12 @@ describe('SaleTunnel', () => {
250
252
  * Make sure the payment schedule is displayed.
251
253
  */
252
254
  screen.getByRole('heading', { name: 'Payment schedule' });
253
- paymentSchedule.forEach((installment, index) => {
255
+ paymentPlan.payment_schedule.forEach((installment, index) => {
254
256
  const row = screen.getByTestId(installment.id);
255
257
  const cells = getAllByRole(row, 'cell');
256
258
  expect(cells).toHaveLength(4);
257
259
  expect(cells[0]).toHaveTextContent((index + 1).toString());
258
- expect(cells[1]).toHaveTextContent(
259
- priceFormatter(installment.currency, installment.amount).replace(/(\u202F|\u00a0)/g, ' '),
260
- );
260
+ expect(cells[1]).toHaveTextContent(priceFormatter(installment.currency, installment.amount));
261
261
  expect(cells[2]).toHaveTextContent(
262
262
  `Withdrawn on ${dateFormatter.format(new Date(installment.due_date))}`,
263
263
  );
@@ -266,8 +266,30 @@ describe('SaleTunnel', () => {
266
266
 
267
267
  const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
268
268
  expect($totalAmount).toHaveTextContent(
269
+ 'Total' + priceFormatter(product.price_currency, paymentPlan.price),
270
+ );
271
+
272
+ /**
273
+ * Submit voucher and check price
274
+ */
275
+ const paymentPlanVoucher = PaymentPlanFactory({
276
+ discounted_price: 70,
277
+ discount: '-30%',
278
+ }).one();
279
+ fetchMock.get(
280
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT30`,
281
+ paymentPlanVoucher,
282
+ { overwriteRoutes: true },
283
+ );
284
+ await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT30');
285
+ await user.click(screen.getByRole('button', { name: 'Validate' }));
286
+ screen.getByRole('heading', { name: 'Payment schedule' });
287
+ await screen.findByTestId('sale-tunnel__total__amount');
288
+ const $totalAmountVoucher = screen.getByTestId('sale-tunnel__total__amount');
289
+ expect($totalAmountVoucher).toHaveTextContent(
269
290
  'Total' +
270
- priceFormatter(product.price_currency, product.price).replace(/(\u202F|\u00a0)/g, ' '),
291
+ priceFormatter(product.price_currency, paymentPlanVoucher.price!) +
292
+ priceFormatter(product.price_currency, paymentPlanVoucher.discounted_price!),
271
293
  );
272
294
 
273
295
  /**
@@ -22,7 +22,7 @@ import {
22
22
  CredentialProductFactory,
23
23
  CreditCardFactory,
24
24
  EnrollmentFactory,
25
- PaymentInstallmentFactory,
25
+ PaymentPlanFactory,
26
26
  } from 'utils/test/factories/joanie';
27
27
  import { Priority } from 'types';
28
28
  import { render } from 'utils/test/render';
@@ -163,15 +163,15 @@ describe.each([
163
163
  is_main: true,
164
164
  }).one();
165
165
  const order = OrderFactory({ state: OrderState.TO_SAVE_PAYMENT_METHOD }).one();
166
-
166
+ const paymentPlan = PaymentPlanFactory().one();
167
167
  fetchMock
168
168
  .get(
169
169
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
170
170
  [],
171
171
  )
172
172
  .get(
173
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
174
- [],
173
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
174
+ paymentPlan,
175
175
  )
176
176
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
177
177
  .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, order)
@@ -179,15 +179,13 @@ describe.each([
179
179
  overwriteRoutes: true,
180
180
  });
181
181
 
182
- render(<Wrapper product={product} isWithdrawable={true} />, {
182
+ render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
183
183
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
184
184
  });
185
185
  nbApiCalls += 1; // useProductOrder call.
186
186
  nbApiCalls += 1; // get user account call.
187
187
  nbApiCalls += 1; // get user preferences call.
188
- if (product.type === ProductType.CREDENTIAL) {
189
- nbApiCalls += 1; // product payment-schedule call
190
- }
188
+ nbApiCalls += 1; // product payment-schedule call
191
189
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
192
190
 
193
191
  const user = userEvent.setup({ delay: null });
@@ -253,30 +251,28 @@ describe.each([
253
251
  is_main: true,
254
252
  }).one();
255
253
  const deferred = new Deferred();
256
-
254
+ const paymentPlan = PaymentPlanFactory().one();
257
255
  fetchMock
258
256
  .get(
259
257
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
260
258
  [],
261
259
  )
262
260
  .get(
263
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
264
- [],
261
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
262
+ paymentPlan,
265
263
  )
266
264
  .post('https://joanie.endpoint/api/v1.0/orders/', deferred.promise)
267
265
  .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
268
266
  overwriteRoutes: true,
269
267
  });
270
268
 
271
- render(<Wrapper product={product} isWithdrawable={true} />, {
269
+ render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
272
270
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
273
271
  });
274
272
  nbApiCalls += 1; // useProductOrder get order with filters
275
273
  nbApiCalls += 1; // get user account call.
276
274
  nbApiCalls += 1; // get user preferences call.
277
- if (product.type === ProductType.CREDENTIAL) {
278
- nbApiCalls += 1; // get product payment schedule.
279
- }
275
+ nbApiCalls += 1; // get paymentPlan call.
280
276
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
281
277
 
282
278
  const user = userEvent.setup({ delay: null });
@@ -328,15 +324,15 @@ describe.each([
328
324
  }).one();
329
325
  const creditCard = CreditCardFactory().one();
330
326
  const order = OrderFactory({ state: OrderState.TO_SAVE_PAYMENT_METHOD }).one();
331
-
327
+ const paymentPlan = PaymentPlanFactory().one();
332
328
  fetchMock
333
329
  .get(
334
330
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
335
331
  [order],
336
332
  )
337
333
  .get(
338
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
339
- [],
334
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
335
+ paymentPlan,
340
336
  )
341
337
  .get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
342
338
  overwriteRoutes: true,
@@ -345,7 +341,7 @@ describe.each([
345
341
  overwriteRoutes: true,
346
342
  });
347
343
 
348
- render(<Wrapper product={product} isWithdrawable={true} />, {
344
+ render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
349
345
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
350
346
  });
351
347
 
@@ -364,15 +360,15 @@ describe.each([
364
360
  }).one();
365
361
  const creditCard = CreditCardFactory().one();
366
362
  const order = OrderFactory({ state }).one();
367
-
363
+ const paymentPlan = PaymentPlanFactory().one();
368
364
  fetchMock
369
365
  .get(
370
366
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
371
367
  [order],
372
368
  )
373
369
  .get(
374
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
375
- [],
370
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
371
+ paymentPlan,
376
372
  )
377
373
  .get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
378
374
  overwriteRoutes: true,
@@ -381,7 +377,7 @@ describe.each([
381
377
  overwriteRoutes: true,
382
378
  });
383
379
 
384
- render(<Wrapper product={product} isWithdrawable={true} />, {
380
+ render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
385
381
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
386
382
  });
387
383
 
@@ -395,18 +391,19 @@ describe.each([
395
391
  it('should show the product payment schedule', async () => {
396
392
  const intl = createIntl({ locale: 'en' });
397
393
  const product = ProductFactory().one();
398
- const schedule = PaymentInstallmentFactory().many(2);
394
+ const paymentPlan = PaymentPlanFactory().one();
395
+ const schedule = paymentPlan.payment_schedule;
399
396
  fetchMock
400
397
  .get(
401
398
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
402
399
  [],
403
400
  )
404
401
  .get(
405
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
406
- schedule,
402
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
403
+ paymentPlan,
407
404
  );
408
405
 
409
- render(<Wrapper product={product} isWithdrawable={true} />, {
406
+ render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
410
407
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
411
408
  });
412
409
 
@@ -448,7 +445,8 @@ describe.each([
448
445
 
449
446
  const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
450
447
  expect($totalAmount).toHaveTextContent(
451
- 'Total' + formatPrice(product.price, product.price_currency).replace(/(\u202F|\u00a0)/g, ' '),
448
+ 'Total' +
449
+ formatPrice(paymentPlan.price, product.price_currency).replace(/(\u202F|\u00a0)/g, ' '),
452
450
  );
453
451
  });
454
452
 
@@ -473,39 +471,49 @@ describe.each([
473
471
  }).one(),
474
472
  ],
475
473
  }).one();
476
-
474
+ const paymentPlan = PaymentPlanFactory({ discounted_price: 80 }).one();
477
475
  if (product.type === ProductType.CERTIFICATE) {
478
476
  enrollmentDiscounted.offerings[0].product = product;
479
-
480
- fetchMock.get(
481
- `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`,
482
- {
483
- results: [],
484
- next: null,
485
- previous: null,
486
- count: 0,
487
- },
488
- );
489
-
477
+ fetchMock
478
+ .get(
479
+ `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`,
480
+ {
481
+ results: [],
482
+ next: null,
483
+ previous: null,
484
+ count: 0,
485
+ },
486
+ )
487
+ .get(
488
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
489
+ paymentPlan,
490
+ );
490
491
  render(
491
- <Wrapper product={product} enrollment={enrollmentDiscounted} isWithdrawable={true} />,
492
+ <Wrapper
493
+ product={product}
494
+ enrollment={enrollmentDiscounted}
495
+ isWithdrawable={true}
496
+ paymentPlan={paymentPlan}
497
+ />,
492
498
  { queryOptions: { client: createTestQueryClient({ user: richieUser }) } },
493
499
  );
494
500
 
495
501
  const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
496
502
  expect($totalAmount).toHaveTextContent(
497
503
  'Total' +
498
- formatPrice(
499
- enrollmentDiscounted.offerings[0].rules?.discounted_price!,
500
- product.price_currency,
501
- ).replace(/(\u202F|\u00a0)/g, ' '),
504
+ formatPrice(paymentPlan.price!, product.price_currency).replace(/(\u202F|\u00a0)/g, ' ') +
505
+ formatPrice(paymentPlan.discounted_price!, product.price_currency).replace(
506
+ /(\u202F|\u00a0)/g,
507
+ ' ',
508
+ ),
502
509
  );
503
510
  }
504
511
  });
505
512
 
506
513
  it('should show the product payment schedule with discounted price', async () => {
507
514
  const intl = createIntl({ locale: 'en' });
508
- const schedule = PaymentInstallmentFactory().many(2);
515
+ const paymentPlan = PaymentPlanFactory({ discounted_price: 80 }).one();
516
+ const schedule = paymentPlan.payment_schedule;
509
517
 
510
518
  const offering = OfferingFactory({
511
519
  product: ProductFactory({
@@ -525,13 +533,21 @@ describe.each([
525
533
  [],
526
534
  )
527
535
  .get(
528
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
529
- schedule,
536
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
537
+ paymentPlan,
530
538
  );
531
539
 
532
- render(<Wrapper product={product} offering={offering} isWithdrawable={true} />, {
533
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
534
- });
540
+ render(
541
+ <Wrapper
542
+ paymentPlan={paymentPlan}
543
+ product={product}
544
+ offering={offering}
545
+ isWithdrawable={true}
546
+ />,
547
+ {
548
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
549
+ },
550
+ );
535
551
 
536
552
  if (product.type === ProductType.CREDENTIAL) {
537
553
  await screen.findByRole('heading', { level: 4, name: 'Payment schedule' });
@@ -569,7 +585,8 @@ describe.each([
569
585
  const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
570
586
  expect($totalAmount).toHaveTextContent(
571
587
  'Total' +
572
- formatPrice(offering!.rules!.discounted_price!, product.price_currency).replace(
588
+ formatPrice(paymentPlan.price!, product.price_currency).replace(/(\u202F|\u00a0)/g, ' ') +
589
+ formatPrice(paymentPlan.discounted_price!, product.price_currency).replace(
573
590
  /(\u202F|\u00a0)/g,
574
591
  ' ',
575
592
  ),
@@ -578,18 +595,18 @@ describe.each([
578
595
 
579
596
  it('should show a walkthrough to explain the subscription process', async () => {
580
597
  const product = ProductFactory().one();
581
- const schedule = PaymentInstallmentFactory().many(2);
598
+ const paymentPlan = PaymentPlanFactory().one();
582
599
  fetchMock
583
600
  .get(
584
601
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
585
602
  [],
586
603
  )
587
604
  .get(
588
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
589
- schedule,
605
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
606
+ paymentPlan,
590
607
  );
591
608
 
592
- render(<Wrapper product={product} isWithdrawable={true} />, {
609
+ render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
593
610
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
594
611
  });
595
612
 
@@ -598,18 +615,18 @@ describe.each([
598
615
 
599
616
  it('should show a checkbox to waive withdrawal right if the product is not withdrawable', async () => {
600
617
  const product = ProductFactory().one();
601
- const schedule = PaymentInstallmentFactory().many(2);
618
+ const paymentPlan = PaymentPlanFactory().one();
602
619
  fetchMock
603
620
  .get(
604
621
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
605
622
  [],
606
623
  )
607
624
  .get(
608
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
609
- schedule,
625
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
626
+ paymentPlan,
610
627
  );
611
628
 
612
- render(<Wrapper product={product} isWithdrawable={false} />, {
629
+ render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={false} />, {
613
630
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
614
631
  });
615
632
 
@@ -618,18 +635,18 @@ describe.each([
618
635
 
619
636
  it('should not show a checkbox to waive withdrawal right if the product is withdrawable', async () => {
620
637
  const product = ProductFactory().one();
621
- const schedule = PaymentInstallmentFactory().many(2);
638
+ const paymentPlan = PaymentPlanFactory().one();
622
639
  fetchMock
623
640
  .get(
624
641
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
625
642
  [],
626
643
  )
627
644
  .get(
628
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
629
- schedule,
645
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
646
+ paymentPlan,
630
647
  );
631
648
 
632
- render(<Wrapper product={product} isWithdrawable={true} />, {
649
+ render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
633
650
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
634
651
  });
635
652
 
@@ -638,18 +655,18 @@ describe.each([
638
655
 
639
656
  it('should show a specific checkbox to waive withdrawal right according to the product type', async () => {
640
657
  const product = ProductFactory().one();
641
- const schedule = PaymentInstallmentFactory().many(2);
658
+ const paymentPlan = PaymentPlanFactory().one();
642
659
  fetchMock
643
660
  .get(
644
661
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
645
662
  [],
646
663
  )
647
664
  .get(
648
- `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
649
- schedule,
665
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
666
+ paymentPlan,
650
667
  );
651
668
 
652
- render(<Wrapper product={product} isWithdrawable={false} />, {
669
+ render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={false} />, {
653
670
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
654
671
  });
655
672
 
@@ -672,4 +689,60 @@ describe.each([
672
689
  screen.getByText(message);
673
690
  });
674
691
  });
692
+
693
+ it('should show appropriate error messages for invalid vouchers', async () => {
694
+ const user = userEvent.setup({ delay: null });
695
+ const paymentPlan = PaymentPlanFactory().one();
696
+ const paymentPlanVoucher = PaymentPlanFactory({
697
+ discounted_price: 70,
698
+ discount: '-30%',
699
+ }).one();
700
+ const product = ProductFactory().one();
701
+ fetchMock
702
+ .get(
703
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
704
+ [],
705
+ )
706
+ .get(
707
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
708
+ paymentPlan,
709
+ )
710
+ .get(
711
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT30`,
712
+ {
713
+ status: 404,
714
+ body: {
715
+ detail: 'No Voucher matches the given query.',
716
+ },
717
+ },
718
+ )
719
+ .get(
720
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT29`,
721
+ {
722
+ status: 429,
723
+ body: {
724
+ detail: 'Too many attempts. Please try again later.',
725
+ },
726
+ },
727
+ )
728
+ .get(
729
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT10`,
730
+ paymentPlanVoucher,
731
+ );
732
+ render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
733
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
734
+ });
735
+ expect(screen.getByLabelText('Voucher code'));
736
+ await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT30');
737
+ await user.click(screen.getByRole('button', { name: 'Validate' }));
738
+ expect(await screen.findByText('The submitted voucher code is not valid.')).toBeInTheDocument();
739
+ await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT29');
740
+ await user.click(screen.getByRole('button', { name: 'Validate' }));
741
+ expect(
742
+ await screen.findByText('Too many attempts. Please try again later.'),
743
+ ).toBeInTheDocument();
744
+ await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT10');
745
+ await user.click(screen.getByRole('button', { name: 'Validate' }));
746
+ expect(await screen.findByText('Discount applied')).toBeInTheDocument();
747
+ });
675
748
  });
@@ -4,6 +4,7 @@ import {
4
4
  CertificateProductFactory,
5
5
  EnrollmentFactory,
6
6
  OfferingFactory,
7
+ PaymentPlanFactory,
7
8
  ProductFactory,
8
9
  } from 'utils/test/factories/joanie';
9
10
  import { PacedCourseFactory } from 'utils/test/factories/richie';
@@ -34,7 +35,9 @@ export default {
34
35
  type Story = StoryObj<typeof SaleTunnel>;
35
36
 
36
37
  export const Credential: Story = {
37
- args: {},
38
+ args: {
39
+ paymentPlan: PaymentPlanFactory().one(),
40
+ },
38
41
  };
39
42
 
40
43
  export const CertificateDiscount: Story = {
@@ -44,5 +47,6 @@ export const CertificateDiscount: Story = {
44
47
  enrollment: EnrollmentFactory({
45
48
  offerings: OfferingFactory({ rules: { discounted_price: 80 } }).many(1),
46
49
  }).one(),
50
+ paymentPlan: PaymentPlanFactory({ discounted_price: 80 }).one(),
47
51
  },
48
52
  };
@@ -9,6 +9,7 @@ import {
9
9
  Organization,
10
10
  Product,
11
11
  ProductType,
12
+ PaymentPlan,
12
13
  } from 'types/Joanie';
13
14
  import { CredentialSaleTunnel } from 'components/SaleTunnel/CredentialSaleTunnel';
14
15
  import { CertificateSaleTunnel } from 'components/SaleTunnel/CertificateSaleTunnel';
@@ -21,6 +22,7 @@ export interface SaleTunnelProps extends Pick<ModalProps, 'isOpen' | 'onClose'>
21
22
  isWithdrawable: boolean;
22
23
  course?: PacedCourse | CourseLight;
23
24
  enrollment?: Enrollment;
25
+ paymentPlan?: PaymentPlan;
24
26
  onFinish?: (order: Order) => void;
25
27
  }
26
28
 
@@ -1,23 +1,26 @@
1
1
  import { useQuery } from '@tanstack/react-query';
2
2
  import { useJoanieApi } from 'contexts/JoanieApiContext';
3
- import { PaymentSchedule } from 'types/Joanie';
3
+ import { PaymentPlan } from 'types/Joanie';
4
4
  import { Nullable } from 'types/utils';
5
+ import { HttpError } from 'utils/errors/HttpError';
5
6
 
6
- type PaymentScheduleFilters = {
7
+ type PaymentPlanFilters = {
7
8
  course_code: string;
8
9
  product_id: string;
10
+ voucher_code?: string;
9
11
  };
10
12
 
11
- export const usePaymentSchedule = (filters: PaymentScheduleFilters) => {
12
- const queryKey = ['courses-products', ...Object.values(filters), 'payment-schedule'];
13
+ export const usePaymentPlan = (filters: PaymentPlanFilters) => {
14
+ const queryKey = ['courses-products', ...Object.values(filters), 'payment-plan'];
13
15
 
14
16
  const api = useJoanieApi();
15
- return useQuery<Nullable<PaymentSchedule>, Error>({
17
+ return useQuery<Nullable<PaymentPlan>, HttpError>({
16
18
  queryKey,
17
19
  queryFn: () =>
18
- api.courses.products.paymentSchedule.get({
20
+ api.courses.products.paymentPlan.get({
19
21
  id: filters.product_id,
20
22
  course_id: filters.course_code,
23
+ voucher_code: filters.voucher_code,
21
24
  }),
22
25
  });
23
26
  };
@@ -482,6 +482,13 @@ export interface PaymentInstallment {
482
482
 
483
483
  export type PaymentSchedule = readonly PaymentInstallment[];
484
484
 
485
+ export interface PaymentPlan {
486
+ price: number;
487
+ discount?: string;
488
+ discounted_price?: number;
489
+ payment_schedule: PaymentSchedule;
490
+ }
491
+
485
492
  // - API
486
493
  export interface AddressCreationPayload extends Omit<Address, 'id' | 'is_main'> {
487
494
  is_main?: boolean;
@@ -491,6 +498,7 @@ interface AbstractOrderProductCreationPayload {
491
498
  product_id: Product['id'];
492
499
  billing_address: Omit<Address, 'id' | 'is_main'>;
493
500
  has_waived_withdrawal_right: boolean;
501
+ voucher_code?: string;
494
502
  }
495
503
 
496
504
  interface OrderCertificateCreationPayload extends AbstractOrderProductCreationPayload {
@@ -547,6 +555,7 @@ export interface CourseQueryFilters extends ResourcesQuery {
547
555
  export interface CourseProductQueryFilters extends ResourcesQuery {
548
556
  id?: Product['id'];
549
557
  course_id?: CourseListItem['id'];
558
+ voucher_code?: string;
550
559
  }
551
560
  export interface OfferingQueryFilters extends PaginatedResourceQuery {
552
561
  id?: Offering['id'];
@@ -684,8 +693,8 @@ export interface API {
684
693
  : Promise<PaginatedResponse<CourseListItem>>;
685
694
  products: {
686
695
  get(filters?: CourseProductQueryFilters): Promise<Nullable<Offering>>;
687
- paymentSchedule: {
688
- get(filters?: CourseProductQueryFilters): Promise<Nullable<PaymentSchedule>>;
696
+ paymentPlan: {
697
+ get(filters?: CourseProductQueryFilters): Promise<Nullable<PaymentPlan>>;
689
698
  };
690
699
  };
691
700
  orders: {
@@ -31,5 +31,6 @@ export enum HttpStatusCode {
31
31
  FORBIDDEN = 403,
32
32
  NOT_FOUND = 404,
33
33
  CONFLICT = 409,
34
+ TOO_MANY_REQUESTS = 429,
34
35
  INTERNAL_SERVER_ERROR = 500,
35
36
  }
@@ -37,6 +37,7 @@ import {
37
37
  ProductType,
38
38
  TargetCourse,
39
39
  UserLight,
40
+ PaymentPlan,
40
41
  } from 'types/Joanie';
41
42
  import { Payment, PaymentProviders } from 'components/PaymentInterfaces/types';
42
43
  import { CourseStateFactory } from 'utils/test/factories/richie';
@@ -360,6 +361,15 @@ export const PaymentInstallmentFactory = factory((): PaymentInstallment => {
360
361
  };
361
362
  });
362
363
 
364
+ export const PaymentPlanFactory = factory((): PaymentPlan => {
365
+ return {
366
+ payment_schedule: PaymentInstallmentFactory().many(2),
367
+ price: faker.number.int({ min: 1, max: 1000, multipleOf: 10 }),
368
+ discount: undefined,
369
+ discounted_price: undefined,
370
+ };
371
+ });
372
+
363
373
  export const OrderEnrollmentFactory = factory((): OrderEnrollment => {
364
374
  return {
365
375
  id: faker.string.uuid(),
@@ -492,5 +502,6 @@ export const SaleTunnelContextFactory = factory(
492
502
  unregisterSubmitCallback: noop,
493
503
  runSubmitCallbacks: () => new Promise((resolve) => resolve()),
494
504
  nextStep: noop,
505
+ setVoucherCode: noop,
495
506
  }),
496
507
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "3.2.1-dev1",
3
+ "version": "3.2.1-dev11",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {