richie-education 2.28.2-dev25 → 2.28.2-dev39

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 (31) hide show
  1. package/js/api/joanie.ts +30 -1
  2. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.spec.tsx +8 -38
  3. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.tsx +15 -22
  4. package/js/components/PaymentScheduleGrid/_styles.scss +13 -0
  5. package/js/components/PaymentScheduleGrid/index.tsx +50 -70
  6. package/js/components/PurchaseButton/index.spec.tsx +27 -12
  7. package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +2 -7
  8. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +22 -3
  9. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +43 -17
  10. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.scss +4 -5
  11. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +34 -11
  12. package/js/components/SaleTunnel/_styles.scss +6 -4
  13. package/js/components/SaleTunnel/index.credential.spec.tsx +5 -7
  14. package/js/components/SaleTunnel/index.full-process.spec.tsx +7 -1
  15. package/js/components/SaleTunnel/index.spec.tsx +127 -61
  16. package/js/hooks/usePaymentSchedule.tsx +23 -0
  17. package/js/hooks/useResources/useResourcesRoot.ts +3 -3
  18. package/js/index.tsx +2 -0
  19. package/js/types/Joanie.ts +31 -0
  20. package/js/utils/OrderHelper/index.ts +13 -0
  21. package/js/utils/test/factories/joanie.ts +32 -19
  22. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +110 -2
  23. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +90 -2
  24. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/_styles.scss +7 -0
  25. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +106 -0
  26. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +212 -0
  27. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +16 -0
  28. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +3 -0
  29. package/package.json +2 -1
  30. package/scss/components/_index.scss +2 -1
  31. /package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/_styles.scss +0 -0
@@ -1,20 +1,43 @@
1
+ import { defineMessages, FormattedMessage } from 'react-intl';
1
2
  import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
2
- import { DashboardAvatar } from 'widgets/Dashboard/components/DashboardAvatar';
3
+ import {
4
+ DashboardAvatar,
5
+ DashboardAvatarVariantEnum,
6
+ } from 'widgets/Dashboard/components/DashboardAvatar';
7
+ import { Organization } from 'types/Joanie';
8
+
9
+ const messages = defineMessages({
10
+ blockTitle: {
11
+ id: 'components.SaleTunnel.Sponsors.SaleTunnelSponsors.blockTitle',
12
+ defaultMessage: 'University',
13
+ description: 'Title for the universities section in the sale tunnel',
14
+ },
15
+ });
3
16
 
4
17
  export const SaleTunnelSponsors = () => {
5
18
  const {
6
19
  props: { organizations },
7
20
  } = useSaleTunnelContext();
8
21
  return (
9
- <div className="sale-tunnel__sponsors">
10
- {organizations?.map((organization) => {
11
- if (organization.logo) {
12
- return (
13
- <img key={organization.id} src={organization.logo!.src} alt={organization.title} />
14
- );
15
- }
16
- return <DashboardAvatar key={organization.id} title={organization.title} />;
17
- })}
18
- </div>
22
+ <>
23
+ <h3 className="block-title">
24
+ <FormattedMessage {...messages.blockTitle} />
25
+ </h3>
26
+ <div className="sale-tunnel__sponsors">{organizations?.map(OrganizationLogo)}</div>
27
+ </>
28
+ );
29
+ };
30
+
31
+ const OrganizationLogo = (organization: Organization) => {
32
+ if (organization.logo) {
33
+ return <img key={organization.id} src={organization.logo!.src} alt={organization.title} />;
34
+ }
35
+
36
+ return (
37
+ <DashboardAvatar
38
+ key={organization.id}
39
+ title={organization.title}
40
+ variant={DashboardAvatarVariantEnum.SQUARE}
41
+ />
19
42
  );
20
43
  };
@@ -33,13 +33,15 @@
33
33
  flex: 1;
34
34
  overflow: hidden;
35
35
  }
36
+
37
+ &__column {
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: var(--c--theme--spacings--b);
41
+ }
36
42
  }
37
43
 
38
44
  &__information {
39
- display: flex;
40
- flex-direction: column;
41
- gap: var(--c--theme--spacings--b);
42
-
43
45
  &__billing-address {
44
46
  display: flex;
45
47
  align-items: center;
@@ -56,12 +56,6 @@ describe('SaleTunnel / Credential', () => {
56
56
  return <SaleTunnel {...props} isOpen={true} onClose={() => {}} />;
57
57
  };
58
58
 
59
- const formatPrice = (price: number, currency: string) =>
60
- new Intl.NumberFormat('en', {
61
- currency,
62
- style: 'currency',
63
- }).format(price);
64
-
65
59
  setupJoanieSession();
66
60
 
67
61
  beforeEach(() => {
@@ -98,6 +92,10 @@ describe('SaleTunnel / Credential', () => {
98
92
  `https://joanie.endpoint/api/v1.0/orders/?course_code=${course.code}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
99
93
  [],
100
94
  )
95
+ .get(
96
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
97
+ [],
98
+ )
101
99
  .post('https://joanie.endpoint/api/v1.0/orders/', (url, { body }) => {
102
100
  createOrderPayload = JSON.parse(body as any);
103
101
  return order;
@@ -120,7 +118,7 @@ describe('SaleTunnel / Credential', () => {
120
118
  await screen.findByText(getAddressLabel(billingAddress));
121
119
 
122
120
  const $button = screen.getByRole('button', {
123
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
121
+ name: `Subscribe`,
124
122
  }) as HTMLButtonElement;
125
123
 
126
124
  const $terms = screen.getByLabelText(
@@ -14,6 +14,7 @@ import CourseProductItem from 'widgets/SyllabusCourseRunsList/components/CourseP
14
14
  import {
15
15
  AddressFactory,
16
16
  CredentialOrderWithPaymentFactory,
17
+ PaymentInstallmentFactory,
17
18
  CourseProductRelationFactory,
18
19
  } from 'utils/test/factories/joanie';
19
20
  import { ACTIVE_ORDER_STATES, CourseRun } from 'types/Joanie';
@@ -85,12 +86,17 @@ describe('SaleTunnel', () => {
85
86
  * Initialization.
86
87
  */
87
88
  const relation = CourseProductRelationFactory().one();
89
+ const paymentSchedule = PaymentInstallmentFactory().many(2);
88
90
  const { product, course } = relation;
89
91
 
90
92
  fetchMock.get(
91
93
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/`,
92
94
  relation,
93
95
  );
96
+ fetchMock.get(
97
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
98
+ paymentSchedule,
99
+ );
94
100
  fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
95
101
  const orderQueryParameters = {
96
102
  product_id: product.id,
@@ -269,7 +275,7 @@ describe('SaleTunnel', () => {
269
275
  });
270
276
 
271
277
  const $button = screen.getByRole('button', {
272
- name: `Pay ${priceFormatter(product.price_currency, product.price)}`,
278
+ name: `Subscribe`,
273
279
  }) as HTMLButtonElement;
274
280
  await user.click($button);
275
281
 
@@ -2,6 +2,8 @@ import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react
2
2
  import fetchMock from 'fetch-mock';
3
3
  import queryString from 'query-string';
4
4
  import userEvent from '@testing-library/user-event';
5
+ import { within } from '@testing-library/dom';
6
+ import { createIntl } from 'react-intl';
5
7
  import { OrderState, Product, ProductType } from 'types/Joanie';
6
8
  import {
7
9
  AddressFactory,
@@ -13,6 +15,7 @@ import {
13
15
  CredentialProductFactory,
14
16
  CreditCardFactory,
15
17
  EnrollmentFactory,
18
+ PaymentInstallmentFactory,
16
19
  } from 'utils/test/factories/joanie';
17
20
  import {
18
21
  RichieContextFactory as mockRichieContextFactory,
@@ -30,6 +33,8 @@ import { User } from 'types/User';
30
33
  import { OpenEdxApiProfile } from 'types/openEdx';
31
34
  import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx';
32
35
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
36
+ import { StringHelper } from 'utils/StringHelper';
37
+ import { DEFAULT_DATE_FORMAT } from 'hooks/useDateFormat';
33
38
 
34
39
  jest.mock('utils/context', () => ({
35
40
  __esModule: true,
@@ -72,7 +77,9 @@ describe.each([
72
77
 
73
78
  const course = PacedCourseFactory().one();
74
79
  const enrollment =
75
- productType === ProductType.CERTIFICATE ? EnrollmentFactory().one() : undefined;
80
+ productType === ProductType.CERTIFICATE
81
+ ? EnrollmentFactory({ course_run: { course } }).one()
82
+ : undefined;
76
83
 
77
84
  let richieUser: User;
78
85
  let openApiEdxProfile: OpenEdxApiProfile;
@@ -147,38 +154,6 @@ describe.each([
147
154
  };
148
155
  };
149
156
 
150
- it('should render a payment button with a specific label when a credit card is provided', async () => {
151
- const product = ProductFactory().one();
152
- const creditCard = CreditCardFactory().one();
153
- const address = AddressFactory().one();
154
-
155
- fetchMock.get(`https://joanie.endpoint/api/v1.0/orders/`, [], {
156
- overwriteRoutes: true,
157
- });
158
- fetchMock.get(
159
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
160
- [],
161
- );
162
- fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', [address], {
163
- overwriteRoutes: true,
164
- });
165
- fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
166
- overwriteRoutes: true,
167
- });
168
-
169
- render(<Wrapper product={product} />, {
170
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
171
- });
172
-
173
- const $button = (await screen.findByRole('button', {
174
- name: `Pay in one click ${formatPrice(product.price, product.price_currency)}`,
175
- })) as HTMLButtonElement;
176
-
177
- // a billing address is missing, but the button stays enabled
178
- // this allows the user to get feedback on what's missing to make the payment by clicking on the button
179
- expect($button.disabled).toBe(false);
180
- });
181
-
182
157
  it('should create an order only the first time the payment interface is shown, and not after aborting', async () => {
183
158
  const product = ProductFactory().one();
184
159
  const billingAddress = AddressFactory({
@@ -191,6 +166,10 @@ describe.each([
191
166
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
192
167
  [],
193
168
  )
169
+ .get(
170
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
171
+ [],
172
+ )
194
173
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
195
174
  .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
196
175
  paymentInfo,
@@ -217,9 +196,10 @@ describe.each([
217
196
  const user = userEvent.setup({ delay: null });
218
197
  await user.click($terms);
219
198
 
220
- const $button = screen.getByRole('button', {
221
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
222
- }) as HTMLButtonElement;
199
+ const $button = screen.getByRole<HTMLButtonElement>('button', {
200
+ name: `Subscribe`,
201
+ });
202
+ nbApiCalls += 1; // product payment-schedule call
223
203
 
224
204
  // - Payment button should not be disabled.
225
205
  expect($button.disabled).toBe(false);
@@ -348,6 +328,10 @@ describe.each([
348
328
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
349
329
  [initialOrder],
350
330
  )
331
+ .get(
332
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
333
+ [],
334
+ )
351
335
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
352
336
  .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
353
337
  payment_info: paymentInfo,
@@ -376,9 +360,10 @@ describe.each([
376
360
  nbApiCalls += 1; // useProductOrder get order with filters
377
361
  nbApiCalls += 1; // get user account call.
378
362
  nbApiCalls += 1; // get user preferences call.
363
+ nbApiCalls += 1; // product payment schedule call.
379
364
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
380
365
  const $button = screen.getByRole('button', {
381
- name: `Pay in one click ${formatPrice(product.price, product.price_currency)}`,
366
+ name: `Subscribe`,
382
367
  }) as HTMLButtonElement;
383
368
 
384
369
  // - Payment button should not be disabled.
@@ -387,7 +372,7 @@ describe.each([
387
372
  // - wait for address to be loaded.
388
373
  await screen.findByText(getAddressLabel(billingAddress));
389
374
 
390
- // - User clicks on pay button
375
+ // - User clicks on Subscribe button
391
376
  fetchMock.get(
392
377
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
393
378
  [orderSubmitted],
@@ -472,6 +457,10 @@ describe.each([
472
457
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
473
458
  [orderSubmitted],
474
459
  )
460
+ .get(
461
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
462
+ [],
463
+ )
475
464
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
476
465
  .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
477
466
  payment_info: paymentInfo,
@@ -494,6 +483,7 @@ describe.each([
494
483
  nbApiCalls += 1; // fetcher order for userProductOrder
495
484
  nbApiCalls += 1; // get user account call.
496
485
  nbApiCalls += 1; // get user preferences call.
486
+ nbApiCalls += 1; // get product payment schedule.
497
487
  const apiCalls = fetchMock.calls().map((call) => call[0]);
498
488
  expect(apiCalls).toContain(
499
489
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
@@ -506,7 +496,7 @@ describe.each([
506
496
  await user.click($terms);
507
497
 
508
498
  const $button = screen.getByRole('button', {
509
- name: `Pay in one click ${formatPrice(product.price, product.price_currency)}`,
499
+ name: `Subscribe`,
510
500
  }) as HTMLButtonElement;
511
501
 
512
502
  // - wait for address to be loaded.
@@ -606,6 +596,10 @@ describe.each([
606
596
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
607
597
  [],
608
598
  )
599
+ .get(
600
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
601
+ [],
602
+ )
609
603
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
610
604
  .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
611
605
  payment_info: paymentInfo,
@@ -624,6 +618,7 @@ describe.each([
624
618
  nbApiCalls += 1; // useProductOrder get order with filters
625
619
  nbApiCalls += 1; // get user account call.
626
620
  nbApiCalls += 1; // get user preferences call.
621
+ nbApiCalls += 1; // get product payment schedule.
627
622
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
628
623
 
629
624
  const $terms = screen.getByLabelText(
@@ -633,7 +628,7 @@ describe.each([
633
628
  await user.click($terms);
634
629
 
635
630
  const $button = screen.getByRole('button', {
636
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
631
+ name: `Subscribe`,
637
632
  }) as HTMLButtonElement;
638
633
 
639
634
  // - wait for address to be loaded.
@@ -675,7 +670,7 @@ describe.each([
675
670
  // - Payment button should have been restore to its idle state
676
671
  expect($button.disabled).toBe(false);
677
672
  screen.getByRole('button', {
678
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
673
+ name: 'Subscribe',
679
674
  });
680
675
  });
681
676
 
@@ -691,6 +686,10 @@ describe.each([
691
686
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
692
687
  [],
693
688
  )
689
+ .get(
690
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
691
+ [],
692
+ )
694
693
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
695
694
  .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
696
695
  payment_info: paymentInfo,
@@ -709,6 +708,7 @@ describe.each([
709
708
  nbApiCalls += 1; // useProductOrder get order with filters
710
709
  nbApiCalls += 1; // get user account call.
711
710
  nbApiCalls += 1; // get user preferences call.
711
+ nbApiCalls += 1; // product payment schedule call.
712
712
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
713
713
 
714
714
  const $terms = screen.getByLabelText(
@@ -718,7 +718,7 @@ describe.each([
718
718
  await user.click($terms);
719
719
 
720
720
  const $button = screen.getByRole('button', {
721
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
721
+ name: `Subscribe`,
722
722
  }) as HTMLButtonElement;
723
723
 
724
724
  // - wait for address to be loaded.
@@ -762,7 +762,7 @@ describe.each([
762
762
  // - Payment button should have been restore to its idle state
763
763
  expect($button.disabled).toBe(false);
764
764
  screen.getByRole('button', {
765
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
765
+ name: `Subscribe`,
766
766
  });
767
767
 
768
768
  // - User clicks on pay button again
@@ -783,6 +783,10 @@ describe.each([
783
783
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
784
784
  [],
785
785
  )
786
+ .get(
787
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
788
+ [],
789
+ )
786
790
  .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
787
791
  overwriteRoutes: true,
788
792
  });
@@ -792,7 +796,7 @@ describe.each([
792
796
  });
793
797
 
794
798
  const $button = screen.getByRole('button', {
795
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
799
+ name: 'Subscribe',
796
800
  }) as HTMLButtonElement;
797
801
 
798
802
  // - As all information are provided, payment button should not be disabled.
@@ -811,23 +815,15 @@ describe.each([
811
815
  it('should show a link to the platform terms and conditions', async () => {
812
816
  const product = ProductFactory().one();
813
817
 
814
- const fetchOrderQueryParams =
815
- product.type === ProductType.CREDENTIAL
816
- ? {
817
- course_code: course.code,
818
- product_id: product.id,
819
- state: ['pending', 'validated', 'submitted'],
820
- }
821
- : {
822
- enrollment_id: enrollment?.id,
823
- product_id: product.id,
824
- state: ['pending', 'validated', 'submitted'],
825
- };
826
-
827
- fetchMock.get(
828
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(fetchOrderQueryParams)}`,
829
- [],
830
- );
818
+ fetchMock
819
+ .get(
820
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
821
+ [],
822
+ )
823
+ .get(
824
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
825
+ [],
826
+ );
831
827
 
832
828
  render(<Wrapper product={product} />, {
833
829
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
@@ -836,5 +832,75 @@ describe.each([
836
832
  const $terms = screen.getByRole('link', { name: 'General Terms of Sale' });
837
833
  expect($terms).toHaveAttribute('href', '/en/about/terms-and-conditions/');
838
834
  });
835
+
836
+ it('should show the product payment schedule', async () => {
837
+ const intl = createIntl({ locale: 'en' });
838
+ const product = ProductFactory().one();
839
+ const schedule = PaymentInstallmentFactory().many(2);
840
+ fetchMock
841
+ .get(
842
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
843
+ [],
844
+ )
845
+ .get(
846
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
847
+ schedule,
848
+ );
849
+
850
+ render(<Wrapper product={product} />, {
851
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
852
+ });
853
+
854
+ await screen.findByRole('heading', {
855
+ level: 4,
856
+ name: 'Payment schedule',
857
+ });
858
+
859
+ const scheduleTable = screen.getByRole('table');
860
+ const scheduleTableRows = within(scheduleTable).getAllByRole('row');
861
+ expect(scheduleTableRows).toHaveLength(schedule.length);
862
+
863
+ scheduleTableRows.forEach((row, index) => {
864
+ const installment = schedule[index];
865
+ // A first column should show the installment index
866
+ within(row).getByRole('cell', {
867
+ name: (index + 1).toString(),
868
+ });
869
+ // A 2nd column should show the installment amount
870
+ within(row).getByRole('cell', {
871
+ name: formatPrice(installment.amount, installment.currency),
872
+ });
873
+ // A 3rd column should show the installment withdraw date
874
+ within(row).getByRole('cell', {
875
+ name: `Withdrawn on ${intl.formatDate(installment.due_date, {
876
+ ...DEFAULT_DATE_FORMAT,
877
+ })}`,
878
+ });
879
+ // A 4th column should show the installment state
880
+ within(row).getByRole('cell', {
881
+ name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
882
+ });
883
+ });
884
+ });
885
+
886
+ it('should show a walkthrough to explain the subscription process', async () => {
887
+ const product = ProductFactory().one();
888
+ const schedule = PaymentInstallmentFactory().many(2);
889
+ fetchMock
890
+ .get(
891
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
892
+ [],
893
+ )
894
+ .get(
895
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
896
+ schedule,
897
+ );
898
+
899
+ render(<Wrapper product={product} />, {
900
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
901
+ });
902
+
903
+ screen.getByTestId('walkthrough-banner');
904
+ });
839
905
  },
840
906
  );
@@ -0,0 +1,23 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
3
+ import { PaymentSchedule } from 'types/Joanie';
4
+ import { Nullable } from 'types/utils';
5
+
6
+ type PaymentScheduleFilters = {
7
+ course_code: string;
8
+ product_id: string;
9
+ };
10
+
11
+ export const usePaymentSchedule = (filters: PaymentScheduleFilters) => {
12
+ const queryKey = ['courses-products', ...Object.values(filters), 'payment-schedule'];
13
+
14
+ const api = useJoanieApi();
15
+ return useQuery<Nullable<PaymentSchedule>, Error>({
16
+ queryKey,
17
+ queryFn: () =>
18
+ api.courses.products.paymentSchedule.get({
19
+ id: filters.product_id,
20
+ course_id: filters.course_code,
21
+ }),
22
+ });
23
+ };
@@ -136,21 +136,21 @@ export const useResourcesRoot = <
136
136
  const mutation = (session ? useSessionMutation : useMutation) as typeof useMutation;
137
137
 
138
138
  const writeHandlers = {
139
- create: api.create
139
+ create: api?.create
140
140
  ? mutation({
141
141
  mutationFn: api.create,
142
142
  onSuccess,
143
143
  onError: () => setError(intl.formatMessage(actualMessages.errorCreate)),
144
144
  })
145
145
  : undefined,
146
- update: api.update
146
+ update: api?.update
147
147
  ? mutation({
148
148
  mutationFn: api.update,
149
149
  onSuccess,
150
150
  onError: () => setError(intl.formatMessage(actualMessages.errorUpdate)),
151
151
  })
152
152
  : undefined,
153
- delete: api.delete
153
+ delete: api?.delete
154
154
  ? mutation({
155
155
  mutationFn: api.delete,
156
156
  onSuccess,
package/js/index.tsx CHANGED
@@ -11,6 +11,7 @@ import 'core-js/modules/es.array.iterator';
11
11
  import 'core-js/modules/es.promise';
12
12
  import { IntlProvider } from 'react-intl';
13
13
  import { QueryClientProvider } from '@tanstack/react-query';
14
+ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
14
15
  import countries from 'i18n-iso-countries';
15
16
  import { createRoot } from 'react-dom/client';
16
17
  import createQueryClient from 'utils/react-query/createQueryClient';
@@ -116,6 +117,7 @@ async function render() {
116
117
  const reactRoot = createRoot(rootContainer);
117
118
  reactRoot.render(
118
119
  <QueryClientProvider client={queryClient}>
120
+ <ReactQueryDevtools initialIsOpen={false} />
119
121
  <IntlProvider locale={locale} messages={translatedMessages} defaultLocale="en-US">
120
122
  <Root richieReactSpots={richieReactSpots} />
121
123
  </IntlProvider>
@@ -263,7 +263,10 @@ export enum OrderState {
263
263
  SUBMITTED = 'submitted',
264
264
  CANCELED = 'canceled',
265
265
  PENDING = 'pending',
266
+ PENDING_PAYMENT = 'pending_payment',
266
267
  VALIDATED = 'validated',
268
+ NO_PAYMENT = 'no_payment',
269
+ FAILED_PAYMENT = 'failed_payment',
267
270
  }
268
271
 
269
272
  export const ACTIVE_ORDER_STATES = [OrderState.PENDING, OrderState.VALIDATED, OrderState.SUBMITTED];
@@ -286,6 +289,7 @@ export interface Order {
286
289
  organization_id: Organization['id'];
287
290
  organization: Organization;
288
291
  order_group_id?: OrderGroup['id'];
292
+ payment_schedule?: PaymentSchedule;
289
293
  }
290
294
 
291
295
  export interface CredentialOrder extends Order {
@@ -416,6 +420,22 @@ export interface OrderPaymentInfo {
416
420
  payment_info: Payment;
417
421
  }
418
422
 
423
+ export enum PaymentScheduleState {
424
+ PENDING = 'pending',
425
+ PAID = 'paid',
426
+ REFUSED = 'refused',
427
+ }
428
+
429
+ export interface PaymentInstallment {
430
+ id: string;
431
+ amount: number;
432
+ currency: string;
433
+ due_date: string;
434
+ state: PaymentScheduleState;
435
+ }
436
+
437
+ export type PaymentSchedule = readonly PaymentInstallment[];
438
+
419
439
  // - API
420
440
  export interface AddressCreationPayload extends Omit<Address, 'id' | 'is_main'> {
421
441
  is_main?: boolean;
@@ -436,6 +456,10 @@ export interface OrderCredentialCreationPayload extends AbstractOrderProductCrea
436
456
 
437
457
  export type OrderCreationPayload = OrderCertificateCreationPayload | OrderCredentialCreationPayload;
438
458
 
459
+ export type OrderSubmitInstallmentPayment = {
460
+ credit_card_id?: string;
461
+ };
462
+
439
463
  interface OrderAbortPayload {
440
464
  id: Order['id'];
441
465
  payment_id?: string;
@@ -558,6 +582,10 @@ interface APIUser {
558
582
  };
559
583
  submit(payload: OrderSubmitPayload): Promise<OrderPaymentInfo>;
560
584
  submit_for_signature(id: string): Promise<ContractInvitationLinkResponse>;
585
+ submit_installment_payment(
586
+ id: string,
587
+ payload?: OrderSubmitInstallmentPayment,
588
+ ): Promise<Payment>;
561
589
  };
562
590
  certificates: {
563
591
  download(id: string): Promise<File>;
@@ -614,6 +642,9 @@ export interface API {
614
642
  : Promise<PaginatedResponse<CourseListItem>>;
615
643
  products: {
616
644
  get(filters?: CourseProductQueryFilters): Promise<Nullable<CourseProductRelation>>;
645
+ paymentSchedule: {
646
+ get(filters?: CourseProductQueryFilters): Promise<Nullable<PaymentSchedule>>;
647
+ };
617
648
  };
618
649
  orders: {
619
650
  get(
@@ -5,6 +5,7 @@ import {
5
5
  OrderState,
6
6
  ContractDefinition,
7
7
  NestedCourseOrder,
8
+ PaymentScheduleState,
8
9
  } from 'types/Joanie';
9
10
 
10
11
  export enum OrderStatus {
@@ -16,6 +17,9 @@ export enum OrderStatus {
16
17
  WAITING_COUNTER_SIGNATURE = 'waiting_counter_signature',
17
18
  COMPLETED = 'completed',
18
19
  ON_GOING = 'on_going',
20
+ NO_PAYMENT = 'no_payment',
21
+ PENDING_PAYMENT = 'pending_payment',
22
+ FAILED_PAYMENT = 'failed_payment',
19
23
  }
20
24
 
21
25
  /**
@@ -44,6 +48,9 @@ export class OrderHelper {
44
48
  [OrderState.SUBMITTED]: OrderStatus.SUBMITTED,
45
49
  [OrderState.PENDING]: OrderStatus.PENDING,
46
50
  [OrderState.CANCELED]: OrderStatus.CANCELED,
51
+ [OrderState.NO_PAYMENT]: OrderStatus.NO_PAYMENT,
52
+ [OrderState.PENDING_PAYMENT]: OrderStatus.PENDING_PAYMENT,
53
+ [OrderState.FAILED_PAYMENT]: OrderStatus.PENDING_PAYMENT,
47
54
  };
48
55
 
49
56
  if (order.state in orderStatusMap) {
@@ -87,4 +94,10 @@ export class OrderHelper {
87
94
  !order.contract.organization_signed_on
88
95
  );
89
96
  }
97
+
98
+ static getFailedInstallment(order: Order) {
99
+ return order.payment_schedule?.find(
100
+ (installment) => installment.state === PaymentScheduleState.REFUSED,
101
+ );
102
+ }
90
103
  }