From 2ee42e7ebf2689353a2b1871864c1ee7f7d8c6bd Mon Sep 17 00:00:00 2001 From: Fabien Henon Date: Thu, 10 Jul 2025 17:21:09 +0200 Subject: [PATCH 1/5] feat(pci-project): handle payment challenge ref: #MANAGER-18808 Signed-off-by: Fabien Henon --- packages/manager/apps/pci-project/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/apps/pci-project/package.json b/packages/manager/apps/pci-project/package.json index 65b6eaab2b10..e1d551894f3e 100644 --- a/packages/manager/apps/pci-project/package.json +++ b/packages/manager/apps/pci-project/package.json @@ -69,4 +69,4 @@ "universes": [ "@ovh-ux/manager-public-cloud" ] -} +} \ No newline at end of file From d1b018809f82c49b5b20eeaef6adc05b970c95a7 Mon Sep 17 00:00:00 2001 From: Fabien Henon Date: Mon, 11 Aug 2025 08:47:12 +0200 Subject: [PATCH 2/5] feat(pci-project): add credit payment mean ref: #MANAGER-18805 Signed-off-by: Fabien Henon --- .../src/components/payment/PaymentMethodChallenge.tsx | 8 ++++++-- .../manager/apps/pci-project/src/payment/constants.ts | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/manager/apps/pci-project/src/components/payment/PaymentMethodChallenge.tsx b/packages/manager/apps/pci-project/src/components/payment/PaymentMethodChallenge.tsx index 46206bb4101d..bf4937b57510 100644 --- a/packages/manager/apps/pci-project/src/components/payment/PaymentMethodChallenge.tsx +++ b/packages/manager/apps/pci-project/src/components/payment/PaymentMethodChallenge.tsx @@ -22,6 +22,7 @@ import { TPaymentMethodType, TUserPaymentMethod, } from '@/data/types/payment/payment-method.type'; +import { CHALLENGE_CREDIT_CARD_LENGTH } from '@/payment/constants'; const PAYMENT_TYPES_TO_SUBMIT_IN_CHALLENGE = [ TPaymentMethodType.CREDIT_CARD, @@ -67,7 +68,10 @@ const PaymentMethodChallenge: React.FC = ({ const isValidValue = () => { switch (paymentMethod?.paymentType) { case TPaymentMethodType.CREDIT_CARD: - return value.length === 6 && /^[0-9]+$/.test(value); + return ( + value.length === CHALLENGE_CREDIT_CARD_LENGTH && + /^[0-9]+$/.test(value) + ); case TPaymentMethodType.BANK_ACCOUNT: return value.length > 0 && isIBAN(value); default: @@ -170,7 +174,7 @@ const PaymentMethodChallenge: React.FC = ({ leading-[1.29] tracking-[1.86px] text-[var(--ods-color-text)] top-[68px] left-[10px]" placeholder="XXXX XX" - maxLength={6} + maxLength={CHALLENGE_CREDIT_CARD_LENGTH} value={value} onChange={(e) => setValue(e.target.value)} aria-label={t('pci_project_new_payment_challenge_credit_card')} diff --git a/packages/manager/apps/pci-project/src/payment/constants.ts b/packages/manager/apps/pci-project/src/payment/constants.ts index 2a0c81f19c3a..1f30c21b2de3 100644 --- a/packages/manager/apps/pci-project/src/payment/constants.ts +++ b/packages/manager/apps/pci-project/src/payment/constants.ts @@ -58,3 +58,5 @@ export const CREDIT_PROVISIONING = { export const CONFIRM_CREDIT_CARD_TEST_AMOUNT = 2; export const LANGUAGE_OVERRIDE = { IN: `en-IN`, ASIA: `en-GB` }; + +export const CHALLENGE_CREDIT_CARD_LENGTH = 6; From 6b124c718ecd84b4bd63f435ee61e18765e1ec68 Mon Sep 17 00:00:00 2001 From: Fabien Henon Date: Tue, 12 Aug 2025 15:49:48 +0200 Subject: [PATCH 3/5] refactor(pci-project): always handle challenge from PaymentMethods component ref: #MANAGER-18805 Signed-off-by: Fabien Henon --- .../payment/PaymentMethods.spec.tsx | 83 +------------------ .../src/components/payment/PaymentMethods.tsx | 33 +++----- .../src/pages/creation/steps/PaymentStep.tsx | 1 - 3 files changed, 14 insertions(+), 103 deletions(-) diff --git a/packages/manager/apps/pci-project/src/components/payment/PaymentMethods.spec.tsx b/packages/manager/apps/pci-project/src/components/payment/PaymentMethods.spec.tsx index 595ca1c2de93..4a014ec05ce7 100644 --- a/packages/manager/apps/pci-project/src/components/payment/PaymentMethods.spec.tsx +++ b/packages/manager/apps/pci-project/src/components/payment/PaymentMethods.spec.tsx @@ -501,7 +501,7 @@ describe('PaymentMethods', () => { }); describe('Payment Method Challenge', () => { - it('should render PaymentMethodChallenge when handlePaymentMethodChallenge is true', () => { + it('should render PaymentMethodChallenge', () => { const mockEligibility = createMockEligibility({ actionsRequired: ['challengePaymentMethod'], }); @@ -523,9 +523,7 @@ describe('PaymentMethods', () => { render( - + , ); @@ -534,37 +532,6 @@ describe('PaymentMethods', () => { expect(screen.getByDisplayValue('')).toBeInTheDocument(); // The challenge input }); - it('should not render PaymentMethodChallenge when handlePaymentMethodChallenge is false', () => { - const mockEligibility = createMockEligibility({ - actionsRequired: ['challengePaymentMethod'], - }); - const mockPaymentMethod = createMockUserPaymentMethod({ - paymentType: TPaymentMethodType.CREDIT_CARD, - default: true, - }); - - mockUseEligibility.mockReturnValue( - createMockEligibilityResult(mockEligibility), - ); - mockUsePaymentMethods.mockImplementation((params) => { - if (params?.default) { - return createMockPaymentMethodsResult({ data: [mockPaymentMethod] }); - } - return createMockPaymentMethodsResult({ data: [mockPaymentMethod] }); - }); - - render( - - - , - ); - - // Challenge component should not be rendered when disabled - expect(screen.queryByPlaceholderText('XXXX XX')).not.toBeInTheDocument(); - }); - it('should handle challenge validity changes and call handleValidityChange', async () => { const user = userEvent.setup(); const mockHandleValidityChange = vi.fn(); @@ -591,7 +558,6 @@ describe('PaymentMethods', () => { , @@ -634,7 +600,6 @@ describe('PaymentMethods', () => { , @@ -664,7 +629,6 @@ describe('PaymentMethods', () => { , @@ -714,7 +678,6 @@ describe('PaymentMethods', () => { , @@ -771,7 +734,6 @@ describe('PaymentMethods', () => { , @@ -798,41 +760,6 @@ describe('PaymentMethods', () => { expect(mockQueryClient.invalidateQueries).toHaveBeenCalledTimes(2); }); - it('should resolve immediately when handlePaymentMethodChallenge is false', async () => { - const mockEligibility = createMockEligibility(); - const mockPaymentMethod = createMockUserPaymentMethod({ default: true }); - const paymentMethodRef = React.createRef(); - - mockUseEligibility.mockReturnValue( - createMockEligibilityResult(mockEligibility), - ); - mockUsePaymentMethods.mockReturnValue( - createMockPaymentMethodsResult({ data: [mockPaymentMethod] }), - ); - - render( - - - , - ); - - await waitFor(() => { - expect(paymentMethodRef.current).toBeTruthy(); - }); - - if (!paymentMethodRef.current) { - throw new Error('Payment method ref is null'); - } - - const result = await paymentMethodRef.current.submitPaymentMethod(); - expect(result).toBe(true); - }); - it('should handle submission when no payment method challenge handler exists', async () => { const mockEligibility = createMockEligibility(); const mockPaymentMethod = createMockUserPaymentMethod({ default: true }); @@ -850,7 +777,6 @@ describe('PaymentMethods', () => { , @@ -888,7 +814,6 @@ describe('PaymentMethods', () => { , @@ -916,7 +841,6 @@ describe('PaymentMethods', () => { , @@ -927,7 +851,7 @@ describe('PaymentMethods', () => { }); }); - it('should be valid when challenge is disabled and payment method conditions are met', async () => { + it('should be valid when payment method conditions are met', async () => { const mockHandleValidityChange = vi.fn(); const mockEligibility = createMockEligibility(); const mockPaymentMethod = createMockUserPaymentMethod({ default: true }); @@ -944,7 +868,6 @@ describe('PaymentMethods', () => { , diff --git a/packages/manager/apps/pci-project/src/components/payment/PaymentMethods.tsx b/packages/manager/apps/pci-project/src/components/payment/PaymentMethods.tsx index fcb6565b2732..c07245f6630e 100644 --- a/packages/manager/apps/pci-project/src/components/payment/PaymentMethods.tsx +++ b/packages/manager/apps/pci-project/src/components/payment/PaymentMethods.tsx @@ -25,7 +25,6 @@ export type PaymentMethodsProps = { paymentMethodHandler: React.Ref; handlePaymentMethodChange?: (method: TPaymentMethod) => void; handleSetAsDefaultChange?: (value: boolean) => void; - handlePaymentMethodChallenge?: boolean; handleValidityChange?: (isValid: boolean) => void; }; @@ -81,7 +80,6 @@ export type TPaymentMethodRef = { * paymentMethodHandler={paymentMethodRef} * handlePaymentMethodChange={(method) => console.log('Selected method:', method)} * handleSetAsDefaultChange={(isDefault) => console.log('Set as default:', isDefault)} - * handlePaymentMethodChallenge={true} * handleValidityChange={handlePaymentValidityChange} * /> * + ), + OdsText: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + if (key === 'pci_project_new_payment_btn_continue_credit') { + return 'Continue with Credit'; + } + if (key === 'pci_project_new_payment_credit_explain') { + return 'Select the amount of credit you want to add'; + } + if (key === 'pci_project_new_payment_credit_amount_other') { + return 'Other amount'; + } + if (key === 'pci_project_new_payment_credit_amount_other_label') { + return 'Custom amount'; + } + if (key === 'pci_project_new_payment_credit_info') { + return 'Credit information'; + } + return key; + }, + }), +})); + +describe('CreditPaymentMethodIntegration', () => { + const mockPaymentMethod: TPaymentMethod = { + icon: { + name: 'credit-icon', + data: 'icon-data', + }, + integration: TPaymentMethodIntegration.COMPONENT, + paymentSubType: null, + paymentType: TPaymentMethodType.CREDIT, + }; + + const mockMinimumCredit: ProjectPrice = { + value: 10, + currencyCode: CurrencyCode.EUR, + text: '10.00 €', + }; + + const mockEligibility: TEligibility = { + actionsRequired: [], + minimumCredit: mockMinimumCredit, + paymentMethodsAuthorized: [TEligibilityPaymentMethod.CREDIT], + voucher: null, + }; + + const mockHandleValidityChange = vi.fn(); + const mockHandleCustomSubmitButton = vi.fn(); + const mockPaymentHandler = { current: null }; + + const defaultProps = { + paymentMethod: mockPaymentMethod, + handleValidityChange: mockHandleValidityChange, + eligibility: mockEligibility, + paymentHandler: mockPaymentHandler, + cartId: 'cart-123', + itemId: 1, + handleCustomSubmitButton: mockHandleCustomSubmitButton, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render credit payment integration component', () => { + render(, { + wrapper: createWrapper(), + }); + + expect( + screen.getByText('Select the amount of credit you want to add'), + ).toBeInTheDocument(); + expect(screen.getAllByTestId('ods-card')).toHaveLength(5); // 4 predefined + 1 custom + }); + + it('should display predefined credit amounts based on minimum credit', () => { + render(, { + wrapper: createWrapper(), + }); + + // Check for predefined amounts: min (10.00), 2x (20), 5x (50), 10x (100) + expect( + screen.getByTestId('radio-credit-amount-10.00 €'), + ).toBeInTheDocument(); + expect(screen.getByTestId('radio-credit-amount-20 €')).toBeInTheDocument(); + expect(screen.getByTestId('radio-credit-amount-50 €')).toBeInTheDocument(); + expect(screen.getByTestId('radio-credit-amount-100 €')).toBeInTheDocument(); + expect( + screen.getByTestId('radio-credit-amount-custom'), + ).toBeInTheDocument(); + }); + + it('should handle predefined amount selection', async () => { + render(, { + wrapper: createWrapper(), + }); + + const radioButton = screen.getByTestId('radio-credit-amount-20 €'); + await userEvent.click(radioButton); + + // Should call handleValidityChange with true when amount is selected + expect(mockHandleValidityChange).toHaveBeenCalledWith(true); + }); + + it('should handle custom amount selection', async () => { + render(, { + wrapper: createWrapper(), + }); + + const customRadio = screen.getByTestId('radio-credit-amount-custom'); + await userEvent.click(customRadio); + + // Custom input should appear + expect(screen.getByTestId('input-otherAmount')).toBeInTheDocument(); + expect(screen.getByTestId('ods-form-field')).toBeInTheDocument(); + }); + + it('should validate custom amount input', async () => { + render(, { + wrapper: createWrapper(), + }); + + const customRadio = screen.getByTestId('radio-credit-amount-custom'); + await userEvent.click(customRadio); + + const customInput = screen.getByTestId('input-otherAmount'); + await userEvent.type(customInput, '25'); + + await waitFor(() => { + expect(customInput).toHaveValue(25); // Should be number for number input + expect(mockHandleValidityChange).toHaveBeenCalledWith(true); + }); + }); + + it('should call handleCustomSubmitButton on mount', () => { + render(, { + wrapper: createWrapper(), + }); + + expect(mockHandleCustomSubmitButton).toHaveBeenCalledWith( + 'Continue with Credit', + ); + }); + + it('should handle eligibility without minimum credit', () => { + const eligibilityWithoutMin: TEligibility = { + actionsRequired: [], + minimumCredit: null, + paymentMethodsAuthorized: [TEligibilityPaymentMethod.CREDIT], + voucher: null, + }; + + render( + , + { + wrapper: createWrapper(), + }, + ); + + // Should only show custom amount option + expect( + screen.getByTestId('radio-credit-amount-custom'), + ).toBeInTheDocument(); + expect( + screen.queryByTestId('radio-credit-amount-10 €'), + ).not.toBeInTheDocument(); + }); + + it('should switch between predefined and custom amount selection', async () => { + render(, { + wrapper: createWrapper(), + }); + + // Select predefined amount first + const predefinedRadio = screen.getByTestId('radio-credit-amount-20 €'); + await userEvent.click(predefinedRadio); + + // Custom input should not be visible + expect(screen.queryByTestId('input-otherAmount')).not.toBeInTheDocument(); + + // Then select custom amount + const customRadio = screen.getByTestId('radio-credit-amount-custom'); + await userEvent.click(customRadio); + + // Custom input should appear + expect(screen.getByTestId('input-otherAmount')).toBeInTheDocument(); + }); + + it('should clear custom amount when switching to predefined', async () => { + render(, { + wrapper: createWrapper(), + }); + + // Select custom amount and enter value + const customRadio = screen.getByTestId('radio-credit-amount-custom'); + await userEvent.click(customRadio); + + const customInput = screen.getByTestId('input-otherAmount'); + await userEvent.type(customInput, '25'); + + // Switch to predefined amount + const predefinedRadio = screen.getByTestId('radio-credit-amount-20 €'); + await userEvent.click(predefinedRadio); + + // Switch back to custom - input should be empty + await userEvent.click(customRadio); + + await waitFor(() => { + const newCustomInput = screen.getByTestId('input-otherAmount'); + expect(newCustomInput).toHaveValue(null); // Input is reset to null, not empty string + }); + }); + + it('should not call handleCustomSubmitButton when not provided', () => { + const mockFn = vi.fn(); + render( + , + { + wrapper: createWrapper(), + }, + ); + + expect(mockFn).not.toHaveBeenCalled(); + }); + + it('should handle form validation correctly', () => { + const { rerender } = render( + , + { + wrapper: createWrapper(), + }, + ); + + // Component should render without errors - use actual text content + expect( + screen.getByText('Select the amount of credit you want to add'), + ).toBeInTheDocument(); + + // Re-render to test component stability + rerender(); + expect( + screen.getByText('Select the amount of credit you want to add'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/apps/pci-project/src/components/payment/integrations/CreditPaymentMethodIntegration.tsx b/packages/manager/apps/pci-project/src/components/payment/integrations/CreditPaymentMethodIntegration.tsx index 7f1359d73a54..cc2d67aa552a 100644 --- a/packages/manager/apps/pci-project/src/components/payment/integrations/CreditPaymentMethodIntegration.tsx +++ b/packages/manager/apps/pci-project/src/components/payment/integrations/CreditPaymentMethodIntegration.tsx @@ -1,29 +1,43 @@ -import { CurrencyCode } from '@ovh-ux/manager-react-components'; import { + ODS_BUTTON_COLOR, ODS_CARD_COLOR, ODS_INPUT_TYPE, ODS_TEXT_PRESET, } from '@ovhcloud/ods-components'; import { + OdsButton, OdsCard, OdsFormField, OdsInput, OdsRadio, OdsText, } from '@ovhcloud/ods-components/react'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { CREDITS_PREDEFINED_AMOUNT_SEQUENCE } from '@/payment/constants'; -import { TPaymentMethod } from '@/data/types/payment/payment-method.type'; +import { + CREDIT_ORDER_CART, + CREDITS_PREDEFINED_AMOUNT_SEQUENCE, +} from '@/payment/constants'; +import { + TPaymentMethod, + TPaymentMethodIntegrationRef, +} from '@/data/types/payment/payment-method.type'; import { ProjectPrice, TEligibility, } from '@/data/types/payment/eligibility.type'; +import { TCart } from '@/data/types/payment/cart.type'; +import { useGetCreditAddonOption } from '@/data/hooks/payment/useCart'; +import { addCartCreditOption } from '@/data/api/payment/cart'; type CreditPaymentMethodIntegrationProps = { paymentMethod: TPaymentMethod; handleValidityChange: (isValid: boolean) => void; eligibility: TEligibility; + paymentHandler: React.Ref; + cartId: string; + itemId: number; + handleCustomSubmitButton?: (btn: string) => void; }; const getAmountToPriceFormat = ( @@ -40,8 +54,16 @@ const getAmountToPriceFormat = ( const CreditPaymentMethodIntegration: React.FC = ({ handleValidityChange, eligibility, + paymentHandler, + cartId, + itemId, + handleCustomSubmitButton, }) => { - const { t } = useTranslation('payment/integrations/credit'); + const { t } = useTranslation([ + 'payment/integrations/credit', + 'payment/integrations/credit/confirmation', + 'new/payment', + ]); const minAmount = eligibility.minimumCredit; const predefinedAmounts: ProjectPrice[] = minAmount ? [ @@ -61,6 +83,13 @@ const CreditPaymentMethodIntegration: React.FC(null); + const [isConfirmationState, setIsConfirmationState] = useState( + false, + ); + const [isFinalizing, setIsFinalizing] = useState(false); + const confirmationResolveRef = useRef<(value: boolean) => void>(() => {}); + + const { data: creditAddonOption } = useGetCreditAddonOption(cartId); const handleSelectedAmount = (amount: ProjectPrice) => { setSelectedAmount(amount); @@ -84,6 +113,14 @@ const CreditPaymentMethodIntegration: React.FC { + if (handleCustomSubmitButton) { + handleCustomSubmitButton( + t('pci_project_new_payment_btn_continue_credit', { ns: 'new/payment' }), + ); + } + }, []); + useEffect(() => { handleValidityChange( !!selectedAmount || @@ -98,9 +135,84 @@ const CreditPaymentMethodIntegration: React.FC { + return { + registerPaymentMethod: async ( + paymentMethod: TPaymentMethod, + cart: TCart, + ) => { + if (creditAddonOption && creditAddonOption.prices.length > 0) { + const amount = selectCustomAmount + ? creditCustomAmount + : selectedAmount; + + const res = await addCartCreditOption(cartId, { + planCode: CREDIT_ORDER_CART.planCode, + quantity: Math.floor( + (amount?.value ?? 0) / creditAddonOption.prices[0].price.value, + ), + duration: creditAddonOption.prices[0].duration, + pricingMode: creditAddonOption.prices[0].pricingMode, + itemId, + }); + console.log( + 'Register credit payment method', + paymentMethod, + cart, + res, + creditCustomAmount, + selectedAmount, + ); + } + return true; + }, + onCheckoutRetrieved: async (cart: TCart) => { + console.log('on checkout credit', cart); + if (cart.prices.withTax.value !== 0) { + // We need to pay credits + setIsConfirmationState(true); + + // We create a promise to wait for user validation + const promise = new Promise((resolve) => { + confirmationResolveRef.current = (r: boolean) => { + setIsFinalizing(true); + resolve(r); + }; + }); + + return promise; + } + return true; + }, + onCartFinalized: async (cart: TCart) => { + console.log('on cart finalized credit', cart); + setIsFinalizing(false); + if (!cart.url || !window.top) { + return true; + } + window.top.location.href = cart.url; + return false; + }, + }; + }, + [ + selectCustomAmount, + creditCustomAmount, + selectedAmount, + creditAddonOption, + itemId, + ], + ); + return (
- {t('pci_project_new_payment_credit_explain')} + + {t('pci_project_new_payment_credit_explain', { + ns: 'payment/integrations/credit', + })} +
{predefinedAmounts.map((amount) => ( @@ -152,7 +264,9 @@ const CreditPaymentMethodIntegration: React.FC - {t('pci_project_new_payment_credit_amount_other')} + {t('pci_project_new_payment_credit_amount_other', { + ns: 'payment/integrations/credit', + })} @@ -161,7 +275,9 @@ const CreditPaymentMethodIntegration: React.FC - {t('pci_project_new_payment_credit_info')} + {t('pci_project_new_payment_credit_info', { + ns: 'payment/integrations/credit', + })} + + {isConfirmationState && ( + + + {t('pci_project_new_payment_credit_confirmation_title', { + ns: 'payment/integrations/credit/confirmation', + })} + + + + {t('pci_project_new_payment_credit_thanks', { + ns: 'payment/integrations/credit/confirmation', + })} + + + + {t('pci_project_new_payment_credit_explain', { + ns: 'payment/integrations/credit/confirmation', + amount: (selectCustomAmount ? creditCustomAmount : selectedAmount) + ?.text, + })} + + + + {t('pci_project_new_payment_credit_info', { + ns: 'payment/integrations/credit/confirmation', + })} + + + confirmationResolveRef.current(true)} + isLoading={isFinalizing} + > + + )}
); }; diff --git a/packages/manager/apps/pci-project/src/components/payment/integrations/PaymentMethodIntegration.spec.tsx b/packages/manager/apps/pci-project/src/components/payment/integrations/PaymentMethodIntegration.spec.tsx new file mode 100644 index 000000000000..c16e8aced4c0 --- /dev/null +++ b/packages/manager/apps/pci-project/src/components/payment/integrations/PaymentMethodIntegration.spec.tsx @@ -0,0 +1,203 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import PaymentMethodIntegration from './PaymentMethodIntegration'; +import { + TPaymentMethod, + TPaymentMethodType, + TPaymentMethodIntegrationRef, +} from '@/data/types/payment/payment-method.type'; +import { TEligibility } from '@/data/types/payment/eligibility.type'; + +// Mock the CreditPaymentMethodIntegration component +vi.mock('./CreditPaymentMethodIntegration', () => ({ + default: vi.fn(() => ( +
+ Credit Payment Integration +
+ )), +})); + +describe('PaymentMethodIntegration', () => { + const mockHandleValidityChange = vi.fn(); + const mockHandleCustomSubmitButton = vi.fn(); + const mockPaymentHandler = React.createRef(); + + const mockEligibility: TEligibility = { + actionsRequired: [], + paymentMethodsAuthorized: [], + minimumCredit: { + value: 10, + currencyCode: 'EUR' as never, + text: '10.00 €', + }, + voucher: null, + }; + + const mockCreditPaymentMethod: TPaymentMethod = { + paymentType: TPaymentMethodType.CREDIT, + integration: 'NONE' as never, + icon: { + name: 'credit', + data: undefined, + url: undefined, + componentIcon: undefined, + }, + }; + + const mockCardPaymentMethod: TPaymentMethod = { + paymentType: TPaymentMethodType.CREDIT_CARD, + integration: 'COMPONENT' as never, + icon: { + name: 'visa', + data: undefined, + url: undefined, + componentIcon: undefined, + }, + }; + + const defaultProps = { + handleValidityChange: mockHandleValidityChange, + eligibility: mockEligibility, + paymentHandler: mockPaymentHandler, + cartId: 'cart-123', + itemId: 456, + handleCustomSubmitButton: mockHandleCustomSubmitButton, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render CreditPaymentMethodIntegration for CREDIT payment type', () => { + render( + , + ); + + expect( + screen.getByTestId('credit-payment-integration'), + ).toBeInTheDocument(); + expect(screen.getByText('Credit Payment Integration')).toBeInTheDocument(); + }); + + it('should render nothing for non-CREDIT payment types', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + expect( + screen.queryByTestId('credit-payment-integration'), + ).not.toBeInTheDocument(); + }); + + it('should render nothing when paymentMethod is null', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when paymentMethod is undefined', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should handle SEPA payment type (should render nothing)', () => { + const sepaPaymentMethod: TPaymentMethod = { + paymentType: TPaymentMethodType.SEPA_DIRECT_DEBIT, + integration: 'NONE' as never, + icon: { + name: 'sepa', + data: undefined, + url: undefined, + componentIcon: undefined, + }, + }; + + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should handle PayPal payment type (should render nothing)', () => { + const paypalPaymentMethod: TPaymentMethod = { + paymentType: TPaymentMethodType.PAYPAL, + integration: 'IN_CONTEXT' as never, + icon: { + name: 'paypal', + data: undefined, + url: undefined, + componentIcon: undefined, + }, + }; + + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should handle bank account payment type (should render nothing)', () => { + const bankPaymentMethod: TPaymentMethod = { + paymentType: TPaymentMethodType.BANK_ACCOUNT, + integration: 'BANK_TRANSFER' as never, + icon: { + name: 'bank', + data: undefined, + url: undefined, + componentIcon: undefined, + }, + }; + + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should handle deferred payment account type (should render nothing)', () => { + const deferredPaymentMethod: TPaymentMethod = { + paymentType: TPaymentMethodType.DEFERRED_PAYMENT_ACCOUNT, + integration: 'NONE' as never, + icon: { + name: 'deferred', + data: undefined, + url: undefined, + componentIcon: undefined, + }, + }; + + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/packages/manager/apps/pci-project/src/components/payment/integrations/PaymentMethodIntegration.tsx b/packages/manager/apps/pci-project/src/components/payment/integrations/PaymentMethodIntegration.tsx index a99eb11a8d07..9722708d2305 100644 --- a/packages/manager/apps/pci-project/src/components/payment/integrations/PaymentMethodIntegration.tsx +++ b/packages/manager/apps/pci-project/src/components/payment/integrations/PaymentMethodIntegration.tsx @@ -1,22 +1,76 @@ -import React, { useState } from 'react'; +import React, { useImperativeHandle } from 'react'; import { TPaymentMethod, + TPaymentMethodIntegrationRef, TPaymentMethodType, } from '@/data/types/payment/payment-method.type'; import CreditPaymentMethodIntegration from './CreditPaymentMethodIntegration'; import { TEligibility } from '@/data/types/payment/eligibility.type'; +import { TCart } from '@/data/types/payment/cart.type'; type PaymentMethodIntegrationProps = { paymentMethod?: TPaymentMethod | null; handleValidityChange: (isValid: boolean) => void; eligibility: TEligibility; + paymentHandler: React.Ref; + cartId: string; + itemId: number; + handleCustomSubmitButton?: (btn: string) => void; }; const PaymentMethodIntegration: React.FC = ({ paymentMethod, handleValidityChange, eligibility, + paymentHandler, + cartId, + itemId, + handleCustomSubmitButton, }) => { + const paymentHandlerRef = React.useRef(null); + + useImperativeHandle( + paymentHandler, + () => { + return { + registerPaymentMethod: async ( + paymentMethodToRegister: TPaymentMethod, + cart: TCart, + ) => { + if ( + paymentHandlerRef.current && + paymentHandlerRef.current.registerPaymentMethod + ) { + return paymentHandlerRef.current.registerPaymentMethod( + paymentMethodToRegister, + cart, + ); + } + return true; + }, + onCheckoutRetrieved: async (cart: TCart) => { + if ( + paymentHandlerRef.current && + paymentHandlerRef.current.onCheckoutRetrieved + ) { + return paymentHandlerRef.current.onCheckoutRetrieved(cart); + } + return true; + }, + onCartFinalized: async (cart: TCart) => { + if ( + paymentHandlerRef.current && + paymentHandlerRef.current.onCartFinalized + ) { + return paymentHandlerRef.current.onCartFinalized(cart); + } + return true; + }, + }; + }, + [paymentHandlerRef], + ); + switch (paymentMethod?.paymentType) { case TPaymentMethodType.CREDIT: return ( @@ -24,6 +78,10 @@ const PaymentMethodIntegration: React.FC = ({ paymentMethod={paymentMethod} handleValidityChange={handleValidityChange} eligibility={eligibility} + paymentHandler={paymentHandlerRef} + cartId={cartId} + itemId={itemId} + handleCustomSubmitButton={handleCustomSubmitButton} /> ); diff --git a/packages/manager/apps/pci-project/src/data/api/cart.spec.ts b/packages/manager/apps/pci-project/src/data/api/cart.spec.ts index 5e0cbfa78d62..e3f4b3af39dd 100644 --- a/packages/manager/apps/pci-project/src/data/api/cart.spec.ts +++ b/packages/manager/apps/pci-project/src/data/api/cart.spec.ts @@ -213,6 +213,12 @@ describe('cart API', () => { description: 'Test cart', expire: '2024-12-31T23:59:59Z', readonly: false, + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', }; mockedV6Get.mockResolvedValue({ data: mockCart }); @@ -305,6 +311,12 @@ describe('cart API', () => { description: 'Test cart', expire: '2024-12-31T23:59:59Z', readonly: false, + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', }; mockedV6Post.mockResolvedValue({ data: mockCart }); @@ -499,6 +511,12 @@ describe('cart API', () => { description: 'Test cart', expire: '2024-12-31', readonly: false, + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', }; mockedV6Get.mockResolvedValue({ data: mockCart }); diff --git a/packages/manager/apps/pci-project/src/data/api/cart.ts b/packages/manager/apps/pci-project/src/data/api/cart.ts index b84a3b905237..c87d89a7f111 100644 --- a/packages/manager/apps/pci-project/src/data/api/cart.ts +++ b/packages/manager/apps/pci-project/src/data/api/cart.ts @@ -157,6 +157,18 @@ export const checkoutCart = async (cartId: string): Promise => { return data; }; +/** + * Retrieves the checkout information of a specified cart. + * + * @param cartId + * @returns {Promise} The cart checkout, including details, prices, and contracts. + */ +export const getCartCheckout = async (cartId: string): Promise => { + const { data } = await v6.get(`/order/cart/${cartId}/checkout`); + + return data; +}; + /** * Retrieves the summary of a specified cart. * diff --git a/packages/manager/apps/pci-project/src/data/api/payment/cart.spec.ts b/packages/manager/apps/pci-project/src/data/api/payment/cart.spec.ts new file mode 100644 index 000000000000..722835c580d5 --- /dev/null +++ b/packages/manager/apps/pci-project/src/data/api/payment/cart.spec.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { v6 } from '@ovh-ux/manager-core-api'; +import { getPublicCloudOptions, addCartCreditOption } from './cart'; +import { + TCart, + TCartOptionPayload, + TCartProductOption, +} from '@/data/types/payment/cart.type'; + +vi.mock('@ovh-ux/manager-core-api'); + +const mockV6Get = vi.mocked(v6.get); +const mockV6Post = vi.mocked(v6.post); + +describe('cart API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getPublicCloudOptions', () => { + const mockCartProductOptions: TCartProductOption[] = [ + { + planCode: 'credit.default', + prices: [ + { + capacities: ['standard'], + description: 'Credit option', + duration: 'P1M', + interval: 1, + maximumQuantity: 100, + maximumRepeat: 1, + minimumQuantity: 1, + minimumRepeat: 1, + price: { + value: 10, + currencyCode: 'EUR', + text: '10.00 €', + }, + priceInUcents: 1000, + pricingMode: 'default', + pricingType: 'purchase', + }, + ], + }, + ]; + + it('should call v6.get with correct endpoint and return cloud options', async () => { + mockV6Get.mockResolvedValue({ data: mockCartProductOptions }); + + const result = await getPublicCloudOptions('cart-123', 'project.2018'); + + expect(mockV6Get).toHaveBeenCalledWith( + 'order/cart/cart-123/cloud/options?planCode=project.2018', + ); + expect(result).toEqual(mockCartProductOptions); + }); + + it('should handle API errors', async () => { + const apiError = new Error('API Error'); + mockV6Get.mockRejectedValue(apiError); + + await expect( + getPublicCloudOptions('cart-123', 'project.2018'), + ).rejects.toThrow('API Error'); + }); + + it('should handle empty cart ID', async () => { + mockV6Get.mockResolvedValue({ data: [] }); + + const result = await getPublicCloudOptions('', 'project.2018'); + + expect(mockV6Get).toHaveBeenCalledWith( + 'order/cart//cloud/options?planCode=project.2018', + ); + expect(result).toEqual([]); + }); + + it('should handle special characters in planCode', async () => { + mockV6Get.mockResolvedValue({ data: mockCartProductOptions }); + + await getPublicCloudOptions('cart-123', 'project.2018+special'); + + expect(mockV6Get).toHaveBeenCalledWith( + 'order/cart/cart-123/cloud/options?planCode=project.2018+special', + ); + }); + }); + + describe('addCartCreditOption', () => { + const mockCart: TCart = { + cartId: 'cart-123', + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', + }; + + const mockOptions: TCartOptionPayload = { + duration: 'P1M', + itemId: 456, + planCode: 'credit.default', + pricingMode: 'default', + quantity: 10, + }; + + it('should call v6.post with correct endpoint and payload and return cart', async () => { + mockV6Post.mockResolvedValue({ data: mockCart }); + + const result = await addCartCreditOption('cart-123', mockOptions); + + expect(mockV6Post).toHaveBeenCalledWith( + 'order/cart/cart-123/cloud/options', + mockOptions, + ); + expect(result).toEqual(mockCart); + }); + + it('should handle API errors', async () => { + const apiError = new Error('Failed to add credit option'); + mockV6Post.mockRejectedValue(apiError); + + await expect( + addCartCreditOption('cart-123', mockOptions), + ).rejects.toThrow('Failed to add credit option'); + }); + + it('should pass through all option properties', async () => { + const fullOptions: TCartOptionPayload = { + duration: 'P6M', + itemId: 789, + planCode: 'credit.premium', + pricingMode: 'monthly', + quantity: 5, + }; + + mockV6Post.mockResolvedValue({ data: mockCart }); + + await addCartCreditOption('cart-456', fullOptions); + + expect(mockV6Post).toHaveBeenCalledWith( + 'order/cart/cart-456/cloud/options', + fullOptions, + ); + }); + + it('should handle different cart IDs', async () => { + mockV6Post.mockResolvedValue({ data: mockCart }); + + await addCartCreditOption('different-cart-id', mockOptions); + + expect(mockV6Post).toHaveBeenCalledWith( + 'order/cart/different-cart-id/cloud/options', + mockOptions, + ); + }); + + it('should handle zero quantity', async () => { + const zeroQuantityOptions: TCartOptionPayload = { + ...mockOptions, + quantity: 0, + }; + + mockV6Post.mockResolvedValue({ data: mockCart }); + + await addCartCreditOption('cart-123', zeroQuantityOptions); + + expect(mockV6Post).toHaveBeenCalledWith( + 'order/cart/cart-123/cloud/options', + zeroQuantityOptions, + ); + }); + }); +}); diff --git a/packages/manager/apps/pci-project/src/data/api/payment/cart.ts b/packages/manager/apps/pci-project/src/data/api/payment/cart.ts new file mode 100644 index 000000000000..dcb1d74f388d --- /dev/null +++ b/packages/manager/apps/pci-project/src/data/api/payment/cart.ts @@ -0,0 +1,26 @@ +import { v6 } from '@ovh-ux/manager-core-api'; +import { + TCart, + TCartOptionPayload, + TCartProductOption, +} from '@/data/types/payment/cart.type'; + +export const getPublicCloudOptions = async ( + cartId: string, + planCode: string, +): Promise => { + const { data } = await v6.get( + `order/cart/${cartId}/cloud/options?planCode=${planCode}`, + ); + return data; +}; + +export const addCartCreditOption = async ( + cartId: string, + options: TCartOptionPayload, +): Promise => { + const { data } = await v6.post(`order/cart/${cartId}/cloud/options`, { + ...options, + }); + return data; +}; diff --git a/packages/manager/apps/pci-project/src/data/hooks/payment/useCart.spec.ts b/packages/manager/apps/pci-project/src/data/hooks/payment/useCart.spec.ts new file mode 100644 index 000000000000..663856326f34 --- /dev/null +++ b/packages/manager/apps/pci-project/src/data/hooks/payment/useCart.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useQuery } from '@tanstack/react-query'; +import { useGetCreditAddonOption } from './useCart'; +import { createWrapper } from '@/wrapperRenders'; + +// Mock React Query +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useQuery: vi.fn(), + }; +}); + +// Mock constants +vi.mock('@/payment/constants', () => ({ + CREDIT_ORDER_CART: { + planCode: 'credit.default', + projectPlanCode: 'project.2018', + }, +})); + +// Mock cart API +vi.mock('@/data/api/payment/cart', () => ({ + getPublicCloudOptions: vi.fn(), +})); + +describe('useCart hooks', () => { + const mockUseQuery = vi.mocked(useQuery); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('useGetCreditAddonOption', () => { + it('should call useQuery with correct parameters when cartId is provided', () => { + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + isError: false, + } as never); + + renderHook(() => useGetCreditAddonOption('cart-123'), { + wrapper: createWrapper(), + }); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: ['order/cart/cart-123/cloud/options?planCode=project.2018'], + queryFn: expect.any(Function), + select: expect.any(Function), + enabled: true, + }); + }); + + it('should be disabled when cartId is not provided', () => { + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + isError: false, + } as never); + + renderHook(() => useGetCreditAddonOption(), { + wrapper: createWrapper(), + }); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: ['order/cart/undefined/cloud/options?planCode=project.2018'], + queryFn: expect.any(Function), + select: expect.any(Function), + enabled: false, + }); + }); + + it('should be disabled when cartId is empty string', () => { + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + isError: false, + } as never); + + renderHook(() => useGetCreditAddonOption(''), { + wrapper: createWrapper(), + }); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: ['order/cart//cloud/options?planCode=project.2018'], + queryFn: expect.any(Function), + select: expect.any(Function), + enabled: false, + }); + }); + + it('should return loading state when query is loading', () => { + const mockQueryResult = { + data: undefined, + isLoading: true, + error: null, + isError: false, + }; + + mockUseQuery.mockReturnValue(mockQueryResult as never); + + const { result } = renderHook(() => useGetCreditAddonOption('cart-123'), { + wrapper: createWrapper(), + }); + + expect(result.current).toEqual(mockQueryResult); + }); + + it('should return error state when query fails', () => { + const error = new Error('API Error'); + const mockQueryResult = { + data: undefined, + isLoading: false, + error, + isError: true, + }; + + mockUseQuery.mockReturnValue(mockQueryResult as never); + + const { result } = renderHook(() => useGetCreditAddonOption('cart-123'), { + wrapper: createWrapper(), + }); + + expect(result.current).toEqual(mockQueryResult); + }); + + it('should return data when query succeeds', () => { + const mockData = { + planCode: 'credit.default', + prices: [ + { + price: { value: 10, currencyCode: 'EUR', text: '10.00 €' }, + duration: 'P1M', + pricingMode: 'default', + }, + ], + }; + + const mockQueryResult = { + data: mockData, + isLoading: false, + error: null, + isError: false, + }; + + mockUseQuery.mockReturnValue(mockQueryResult as never); + + const { result } = renderHook(() => useGetCreditAddonOption('cart-123'), { + wrapper: createWrapper(), + }); + + expect(result.current).toEqual(mockQueryResult); + }); + }); +}); diff --git a/packages/manager/apps/pci-project/src/data/hooks/payment/useCart.ts b/packages/manager/apps/pci-project/src/data/hooks/payment/useCart.ts new file mode 100644 index 000000000000..4f256ca81a21 --- /dev/null +++ b/packages/manager/apps/pci-project/src/data/hooks/payment/useCart.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; +import { CREDIT_ORDER_CART } from '@/payment/constants'; +import { getPublicCloudOptions } from '@/data/api/payment/cart'; + +export const useGetCreditAddonOption = (cartId?: string) => + useQuery({ + queryKey: [ + `order/cart/${cartId}/cloud/options?planCode=${CREDIT_ORDER_CART.projectPlanCode}`, + ], + queryFn: async () => + getPublicCloudOptions( + cartId as string, + CREDIT_ORDER_CART.projectPlanCode, + ), + select: (cloudOptions) => { + // Find credit addon option based on planCode and family + const creditOption = cloudOptions?.find( + (option) => option.planCode === CREDIT_ORDER_CART.planCode, + ); + return creditOption; + }, + enabled: !!cartId, + }); diff --git a/packages/manager/apps/pci-project/src/data/hooks/useCart.spec.tsx b/packages/manager/apps/pci-project/src/data/hooks/useCart.spec.tsx index 27264a6a2141..0a95f70cff8c 100644 --- a/packages/manager/apps/pci-project/src/data/hooks/useCart.spec.tsx +++ b/packages/manager/apps/pci-project/src/data/hooks/useCart.spec.tsx @@ -56,6 +56,12 @@ describe('useCart hooks', () => { description: 'Test cart', expire: '2024-12-31', readonly: false, + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', }; const mockCartSummary: CartSummary = { diff --git a/packages/manager/apps/pci-project/src/data/hooks/useCart.tsx b/packages/manager/apps/pci-project/src/data/hooks/useCart.tsx index 631dec686df5..261b2d289a53 100644 --- a/packages/manager/apps/pci-project/src/data/hooks/useCart.tsx +++ b/packages/manager/apps/pci-project/src/data/hooks/useCart.tsx @@ -229,7 +229,9 @@ export const useCreateAndAssignCart = () => */ export const useGetHdsAddonOption = (cartId?: string) => useQuery({ - queryKey: ['cart-hds-addon-option', cartId], + queryKey: [ + `order/cart/${cartId}/cloud/options?planCode=${PCI_PROJECT_ORDER_CART.planCode}`, + ], queryFn: async () => getPublicCloudOptions( cartId as string, diff --git a/packages/manager/apps/pci-project/src/data/types/cart.type.ts b/packages/manager/apps/pci-project/src/data/types/cart.type.ts index c3795e914bd3..5a9a0e8fb0cf 100644 --- a/packages/manager/apps/pci-project/src/data/types/cart.type.ts +++ b/packages/manager/apps/pci-project/src/data/types/cart.type.ts @@ -4,6 +4,12 @@ export type Cart = { expire: string; items?: Array; readonly: boolean | undefined; + prices: { + withTax: { + value: number; + }; + }; + url: string | null; }; export enum PlanCode { diff --git a/packages/manager/apps/pci-project/src/data/types/payment/cart.type.ts b/packages/manager/apps/pci-project/src/data/types/payment/cart.type.ts new file mode 100644 index 000000000000..acaa3d27f207 --- /dev/null +++ b/packages/manager/apps/pci-project/src/data/types/payment/cart.type.ts @@ -0,0 +1,43 @@ +export type TCart = { + cartId: string; + prices: { + withTax: { + value: number; + }; + }; + url: string | null; +}; + +export type TCartOptionPayload = { + duration: string; + itemId: number; + planCode: string; + pricingMode: string; + quantity: number; +}; + +export type TPrice = { + value: number; + currencyCode: string; + text: string; +}; + +export type TCartProductPrice = { + capacities: string[]; + description: string; + duration: string; + interval: number; + maximumQuantity: number; + maximumRepeat: number; + minimumQuantity: number; + minimumRepeat: number; + price: TPrice; + priceInUcents: number; + pricingMode: string; + pricingType: string; +}; + +export type TCartProductOption = { + planCode: string; + prices: TCartProductPrice[]; +}; diff --git a/packages/manager/apps/pci-project/src/data/types/payment/payment-method.type.ts b/packages/manager/apps/pci-project/src/data/types/payment/payment-method.type.ts index 66660ed35a4c..d3ad2e56f77f 100644 --- a/packages/manager/apps/pci-project/src/data/types/payment/payment-method.type.ts +++ b/packages/manager/apps/pci-project/src/data/types/payment/payment-method.type.ts @@ -1,3 +1,5 @@ +import { TCart } from './cart.type'; + export enum TPaymentMethodIntegration { BANK_TRANSFER = 'BANK_TRANSFER', COMPONENT = 'COMPONENT', @@ -93,3 +95,12 @@ export type TAvailablePaymentMethod = TPaymentMethod & { registerableWithTransaction: boolean; readableName?: { key: string; ns: string }; }; + +export type TPaymentMethodIntegrationRef = { + registerPaymentMethod?: ( + paymentMethod: TPaymentMethod, + cart: TCart, + ) => Promise; + onCheckoutRetrieved?: (cart: TCart) => Promise; + onCartFinalized?: (cart: TCart) => Promise; +}; diff --git a/packages/manager/apps/pci-project/src/hooks/useCheckout/useCheckout.spec.ts b/packages/manager/apps/pci-project/src/hooks/useCheckout/useCheckout.spec.ts index 845a413bebad..1f9ba7779d2f 100644 --- a/packages/manager/apps/pci-project/src/hooks/useCheckout/useCheckout.spec.ts +++ b/packages/manager/apps/pci-project/src/hooks/useCheckout/useCheckout.spec.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as cartApi from '@/data/api/cart'; import * as paymentApi from '@/data/api/payment'; import { createWrapper } from '@/wrapperRenders'; -import { useCheckoutWithFidelityAccount } from './useCheckout'; +import { useCheckoutWithFidelityAccount, useCheckoutCart } from './useCheckout'; +import { CartSummary } from '@/data/types/cart.type'; vi.mock('@ovh-ux/manager-react-components', () => ({ useFeatureAvailability: vi.fn(), @@ -102,3 +103,86 @@ describe('useCheckoutWithFidelityAccount', () => { expect(onSuccess).not.toHaveBeenCalled(); }); }); + +describe('useCheckoutCart', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockCartSummary: CartSummary = { + contracts: [], + prices: { + originalWithoutTax: { + value: 90.91, + currencyCode: 'EUR', + text: '90.91 €', + }, + reduction: { value: 0, currencyCode: 'EUR', text: '0.00 €' }, + tax: { value: 9.09, currencyCode: 'EUR', text: '9.09 €' }, + withTax: { value: 100, currencyCode: 'EUR', text: '100.00 €' }, + withoutTax: { value: 90.91, currencyCode: 'EUR', text: '90.91 €' }, + }, + details: [], + orderId: 12345, + url: 'https://payment.example.com', + }; + + it('should checkout cart successfully', async () => { + vi.mocked(cartApi.checkoutCart).mockResolvedValue(mockCartSummary); + + const { result } = renderHook(() => useCheckoutCart(), { + wrapper: createWrapper(), + }); + + let checkoutResult: CartSummary | undefined; + await act(async () => { + checkoutResult = await result.current.mutateAsync({ cartId: 'cart-123' }); + }); + + expect(cartApi.checkoutCart).toHaveBeenCalledWith('cart-123'); + expect(checkoutResult).toEqual(mockCartSummary); + }); + + it('should handle checkout API errors', async () => { + const apiError = new Error('Checkout failed'); + vi.mocked(cartApi.checkoutCart).mockRejectedValue(apiError); + + const { result } = renderHook(() => useCheckoutCart(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await expect( + result.current.mutateAsync({ cartId: 'cart-123' }), + ).rejects.toThrow('Checkout failed'); + }); + + expect(cartApi.checkoutCart).toHaveBeenCalledWith('cart-123'); + }); + + it('should handle different cart IDs', async () => { + vi.mocked(cartApi.checkoutCart).mockResolvedValue(mockCartSummary); + + const { result } = renderHook(() => useCheckoutCart(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ cartId: 'different-cart-id' }); + }); + + expect(cartApi.checkoutCart).toHaveBeenCalledWith('different-cart-id'); + }); + + it('should return mutation state correctly', () => { + const { result } = renderHook(() => useCheckoutCart(), { + wrapper: createWrapper(), + }); + + expect(result.current.isPending).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.data).toBeUndefined(); + expect(typeof result.current.mutate).toBe('function'); + expect(typeof result.current.mutateAsync).toBe('function'); + }); +}); diff --git a/packages/manager/apps/pci-project/src/hooks/useCheckout/useCheckout.ts b/packages/manager/apps/pci-project/src/hooks/useCheckout/useCheckout.ts index 2ff5186d0bc4..89068f1684ea 100644 --- a/packages/manager/apps/pci-project/src/hooks/useCheckout/useCheckout.ts +++ b/packages/manager/apps/pci-project/src/hooks/useCheckout/useCheckout.ts @@ -32,3 +32,11 @@ export const useCheckoutWithFidelityAccount = ({ onSuccess, onError, }); + +export const useCheckoutCart = () => + useMutation({ + mutationFn: async ({ cartId }: { cartId: string }) => { + const cart = await checkoutCart(cartId); + return cart; + }, + }); diff --git a/packages/manager/apps/pci-project/src/pages/creation/Creation.page.tsx b/packages/manager/apps/pci-project/src/pages/creation/Creation.page.tsx index b39943026096..ff82ef0224f4 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/Creation.page.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/Creation.page.tsx @@ -5,7 +5,7 @@ import { Title, } from '@ovh-ux/manager-react-components'; import { ShellContext } from '@ovh-ux/manager-react-shell-client'; -import { useCallback, useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { @@ -18,6 +18,10 @@ import { useConfigForm } from './hooks/useConfigForm'; import ConfigStep from './steps/ConfigStep'; import PaymentStep from './steps/PaymentStep'; import { useStepper } from './hooks/useStepper'; +import { TPaymentMethodRef } from '@/components/payment/PaymentMethods'; +import { getCartCheckout } from '@/data/api/cart'; +import { useCheckoutCart } from '@/hooks/useCheckout/useCheckout'; +import { CartSummary } from '@/data/types/cart.type'; export default function ProjectCreation() { const { t } = useTranslation([ @@ -35,12 +39,17 @@ export default function ProjectCreation() { const [isPaymentMethodValid, setIsPaymentMethodValid] = useState( false, ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [customSubmitButton, setCustomSubmitButton] = useState( + null, + ); const { mutate: createAndAssignCart, data: cart } = useCreateAndAssignCart(); const { mutate: orderProjectItem, data: projectItem } = useOrderProjectItem(); const { mutate: attachConfigurationToCartItem, } = useAttachConfigurationToCartItem(); + const { mutate: checkoutCart } = useCheckoutCart(); const { currentStep, @@ -83,8 +92,72 @@ export default function ProjectCreation() { ); }; + const paymentHandlerRef = React.useRef(null); + const handleCancel = useCallback(() => navigate('..'), [navigate]); - const handlePaymentNext = useCallback(() => {}, []); + const handlePaymentNext = useCallback(async () => { + if (!cart) { + return; + } + + if (paymentHandlerRef.current) { + setIsSubmitting(true); + try { + if (!(await paymentHandlerRef.current.submitPaymentMethod(cart))) { + setIsSubmitting(false); + return; + } + + const cartCheckoutInfo = await getCartCheckout(cart.cartId); + + if (paymentHandlerRef.current.onCheckoutRetrieved) { + if ( + !(await paymentHandlerRef.current.onCheckoutRetrieved({ + ...cartCheckoutInfo, + cartId: cart.cartId, + })) + ) { + setIsSubmitting(false); + return; + } + } + + checkoutCart( + { + cartId: cart.cartId, + }, + { + onSuccess: async (cartFinalized: CartSummary) => { + if ( + paymentHandlerRef.current && + paymentHandlerRef.current.onCartFinalized + ) { + if ( + !(await paymentHandlerRef.current.onCartFinalized({ + ...cartFinalized, + cartId: cart.cartId, + })) + ) { + setIsSubmitting(false); + return; + } + } + console.log( + 'Cart finalized successfully, we can create the project', + ); + setIsSubmitting(false); + }, + onError: () => { + setIsSubmitting(false); + }, + }, + ); + } catch (error) { + console.error('Error during payment submission:', error); + setIsSubmitting(false); + } + } + }, [paymentHandlerRef, cart, isSubmitting]); if (!cart || !projectItem) { return ; @@ -139,10 +212,12 @@ export default function ProjectCreation() { })} next={{ action: handlePaymentNext, - label: t('pci_project_new_payment_btn_continue_default', { - ns: 'new/payment', - }), - isDisabled: !isPaymentMethodValid, + label: + customSubmitButton || + t('pci_project_new_payment_btn_continue_default', { + ns: 'new/payment', + }), + isDisabled: !isPaymentMethodValid || isSubmitting, }} skip={{ action: handleCancel, @@ -156,6 +231,8 @@ export default function ProjectCreation() { console.log('Payment method validity changed:', isValid); setIsPaymentMethodValid(isValid); }} + handleCustomSubmitButton={(btn) => setCustomSubmitButton(btn)} + paymentHandler={paymentHandlerRef} />
diff --git a/packages/manager/apps/pci-project/src/pages/creation/steps/ConfigStep.spec.tsx b/packages/manager/apps/pci-project/src/pages/creation/steps/ConfigStep.spec.tsx index db61767ffab5..d165b013743f 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/steps/ConfigStep.spec.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/steps/ConfigStep.spec.tsx @@ -25,6 +25,12 @@ describe('ConfigStep', () => { description: 'Test cart', expire: '2024-12-31T23:59:59Z', readonly: false, + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', }, cartProjectItem: { cartId: 'cart-123', diff --git a/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.spec.tsx b/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.spec.tsx index 458ee967cb2c..5b67903ffa62 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.spec.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.spec.tsx @@ -43,6 +43,12 @@ describe('PaymentStep', () => { description: 'Test cart', expire: '2024-12-31T23:59:59Z', readonly: false, + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', }, cartProjectItem: { cartId: 'cart-123', @@ -58,6 +64,9 @@ describe('PaymentStep', () => { quantity: 1, }, }, + handleIsPaymentMethodValid: vi.fn(), + paymentHandler: { current: null }, + handleCustomSubmitButton: vi.fn(), }; const mockStartupProgramAmountText = '100.00 €'; diff --git a/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.tsx b/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.tsx index 84780c6d4871..18a17afd1d3a 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.tsx @@ -1,5 +1,7 @@ import { useState } from 'react'; -import PaymentMethods from '@/components/payment/PaymentMethods'; +import PaymentMethods, { + TPaymentMethodRef, +} from '@/components/payment/PaymentMethods'; import { Cart, CartConfiguration, @@ -16,6 +18,8 @@ export type PaymentStepProps = { cart: Cart; cartProjectItem: OrderedProduct; handleIsPaymentMethodValid: (isValid: boolean) => void; + paymentHandler: React.Ref; + handleCustomSubmitButton: (btn: string) => void; }; export type PaymentForm = { @@ -26,6 +30,8 @@ export default function PaymentStep({ cart, cartProjectItem, handleIsPaymentMethodValid, + paymentHandler, + handleCustomSubmitButton, }: PaymentStepProps) { const [paymentForm, setPaymentForm] = useState({ voucherConfiguration: undefined, @@ -57,8 +63,11 @@ export default function PaymentStep({ {}} handleSetAsDefaultChange={() => {}} - paymentMethodHandler={() => {}} + paymentMethodHandler={paymentHandler} handleValidityChange={handleIsPaymentMethodValid} + cartId={cart.cartId} + itemId={cartProjectItem.itemId} + handleCustomSubmitButton={handleCustomSubmitButton} /> {isStartupProgramAvailable && startupProgramAmountText && ( diff --git a/packages/manager/apps/pci-project/src/pages/home/edit/hds-section/HdsSection.spec.tsx b/packages/manager/apps/pci-project/src/pages/home/edit/hds-section/HdsSection.spec.tsx index 8ce82e7334cb..0b7accbdec97 100644 --- a/packages/manager/apps/pci-project/src/pages/home/edit/hds-section/HdsSection.spec.tsx +++ b/packages/manager/apps/pci-project/src/pages/home/edit/hds-section/HdsSection.spec.tsx @@ -97,6 +97,12 @@ const mockCart: Cart = { description: '', expire: '', readonly: false, + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', }; const mockCartSummary: CartSummary = { diff --git a/packages/manager/apps/pci-project/src/pages/home/edit/hds-section/useHds.spec.tsx b/packages/manager/apps/pci-project/src/pages/home/edit/hds-section/useHds.spec.tsx index 507c74eaee17..5b84160bbfee 100644 --- a/packages/manager/apps/pci-project/src/pages/home/edit/hds-section/useHds.spec.tsx +++ b/packages/manager/apps/pci-project/src/pages/home/edit/hds-section/useHds.spec.tsx @@ -134,6 +134,12 @@ describe('useHds hooks', () => { description: '', expire: '', readonly: false, + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', }); vi.mocked(cartApi.assignCart).mockResolvedValue(undefined); @@ -206,6 +212,12 @@ describe('useHds hooks', () => { description: '', expire: '', readonly: false, + prices: { + withTax: { + value: 100, + }, + }, + url: 'https://example.com/cart', }); }); }); diff --git a/packages/manager/apps/pci-project/src/payment/constants.ts b/packages/manager/apps/pci-project/src/payment/constants.ts index fbf0d8c65f7e..52cd191ed46d 100644 --- a/packages/manager/apps/pci-project/src/payment/constants.ts +++ b/packages/manager/apps/pci-project/src/payment/constants.ts @@ -56,6 +56,11 @@ export const CREDIT_PROVISIONING = { PRICE_MODE: 'default', }; +export const CREDIT_ORDER_CART = { + projectPlanCode: 'project.2018', + planCode: 'cloud.credit', +}; + export const CONFIRM_CREDIT_CARD_TEST_AMOUNT = 2; export const LANGUAGE_OVERRIDE = { IN: `en-IN`, ASIA: `en-GB` };