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.
- package/js/api/joanie.ts +30 -1
- package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.spec.tsx +8 -38
- package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.tsx +15 -22
- package/js/components/PaymentScheduleGrid/_styles.scss +13 -0
- package/js/components/PaymentScheduleGrid/index.tsx +50 -70
- package/js/components/PurchaseButton/index.spec.tsx +27 -12
- package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +2 -7
- package/js/components/SaleTunnel/GenericSaleTunnel.tsx +22 -3
- package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +43 -17
- package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.scss +4 -5
- package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +34 -11
- package/js/components/SaleTunnel/_styles.scss +6 -4
- package/js/components/SaleTunnel/index.credential.spec.tsx +5 -7
- package/js/components/SaleTunnel/index.full-process.spec.tsx +7 -1
- package/js/components/SaleTunnel/index.spec.tsx +127 -61
- package/js/hooks/usePaymentSchedule.tsx +23 -0
- package/js/hooks/useResources/useResourcesRoot.ts +3 -3
- package/js/index.tsx +2 -0
- package/js/types/Joanie.ts +31 -0
- package/js/utils/OrderHelper/index.ts +13 -0
- package/js/utils/test/factories/joanie.ts +32 -19
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +110 -2
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +90 -2
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/_styles.scss +7 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +106 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +212 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +16 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +3 -0
- package/package.json +2 -1
- package/scss/components/_index.scss +2 -1
- /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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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: `
|
|
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: `
|
|
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
|
|
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: `
|
|
222
|
-
})
|
|
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: `
|
|
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
|
|
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: `
|
|
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: `
|
|
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:
|
|
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: `
|
|
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: `
|
|
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:
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
|
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
|
|
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
|
|
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>
|
package/js/types/Joanie.ts
CHANGED
|
@@ -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
|
}
|