From 0d17fe299b81d48b6d7d1c32e8bb3117fc26ea6d Mon Sep 17 00:00:00 2001 From: t Date: Wed, 16 Sep 2026 14:30:00 +0200 Subject: [PATCH 1/5] feat(wallets): show wallet and recurring rule connections in an External apps tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context ING-519 and ING-520 gave the wallet form the connection-first payment drawer and the additional integration settings drawer, but nothing in the read views showed what a wallet or one of its recurring rules actually routes to. ING-683 now exposes `connections` on both objects — behaviour plus resolved code — which is what lets the UI tell an explicit choice from one inherited from the customer. This ships the read half, behind the `multi_connection` feature flag, and absorbs ING-528: the shared connection read components are built here together with their first consumer, so ING-523 (subscription) and ING-526 (invoice) can reuse them unchanged. ## Description - Select `connections { category behavior code }` on the `WalletDetails` fragment, for the wallet and for each of its recurring transaction rules. - Add shared, category-agnostic read components under `src/components/connectionSelection/read/`: the connection code chip with its provider avatar, the routing value with its four states (specific, inherited, skipped, nothing resolved), the payment method value, the labeled grid rows, and the two-section composite every view composes. - Add an "External apps" tab to the wallet details page, between "Recurring rule" and "Transactions": "Payment app and settings" (payment connection + payment method) and "Additional app settings" (tax, accounting, CRM). - Render the same two sections per recurring rule, inside the rule's own block. - Move the payment method row off the wallet Overview when the flag is on, so it is not duplicated; the invoice custom sections row stays there. - Wire each section's Edit to the wallet form with a router-state intent flag that auto-opens the matching drawer, mirroring the existing recurring-rule bridge. Everything above is gated: with `multi_connection` off, all three views render exactly as before. Notes for review: - The rows read "(Customer default)" from a new key, as the design draws. The shipped "inherit from customer" key is untouched, so the subscription view still reads the old wording until product settles it. - The design also draws a "(Wallet default)" variant on a rule. `ConnectionRouting.behavior` only says `inherit`, never whether the value came from the wallet or the customer, so only one wording can be rendered today. - The provider URL / invoice sync row of ING-528 is not built here: its only consumer is the invoice tab (ING-526), which is blocked on ING-667. - `Run Codegen` stays red until lago-api#6401 merges — CI builds the schema from lago-api main, which does not expose these fields yet. Fixes ING-527 --- .../AdditionalIntegrationSettingsSelector.tsx | 14 ++ ...ditionalIntegrationSettingsDrawer.test.tsx | 34 +++ .../read/ConnectionCodeChip.tsx | 30 +++ .../read/ConnectionRoutingValue.tsx | 150 ++++++++++++ .../read/ConnectionSettingsSections.tsx | 135 +++++++++++ .../read/PaymentMethodValue.tsx | 48 ++++ .../__tests__/ConnectionRoutingValue.test.tsx | 175 ++++++++++++++ .../__tests__/PaymentMethodValue.test.tsx | 99 ++++++++ .../useConnectionRoutingGridItems.test.tsx | 105 +++++++++ .../read/useConnectionRoutingGridItems.tsx | 56 +++++ .../ConnectionPaymentSettingsSelector.tsx | 14 ++ .../ConnectionPaymentSettingsDrawer.test.tsx | 51 ++++ src/components/wallets/WalletExternalApps.tsx | 77 ++++++ src/components/wallets/WalletInformations.tsx | 34 ++- .../wallets/WalletRecurringRules.tsx | 126 +++++++++- .../__tests__/WalletExternalApps.test.tsx | 223 ++++++++++++++++++ .../__tests__/WalletInformations.test.tsx | 57 ++++- .../__tests__/WalletRecurringRules.test.tsx | 167 ++++++++++++- src/generated/graphql.tsx | 14 +- src/pages/wallet/CreateWallet.tsx | 7 + src/pages/wallet/WalletDetails.tsx | 42 +++- .../wallet/__tests__/CreateWallet.test.tsx | 62 ++++- .../wallet/__tests__/WalletDetails.test.tsx | 58 +++++ src/pages/wallet/components/TopUpSection.tsx | 8 + .../__tests__/TopUpSection.test.tsx | 31 +++ translations/base.json | 13 +- 26 files changed, 1799 insertions(+), 31 deletions(-) create mode 100644 src/components/connectionSelection/read/ConnectionCodeChip.tsx create mode 100644 src/components/connectionSelection/read/ConnectionRoutingValue.tsx create mode 100644 src/components/connectionSelection/read/ConnectionSettingsSections.tsx create mode 100644 src/components/connectionSelection/read/PaymentMethodValue.tsx create mode 100644 src/components/connectionSelection/read/__tests__/ConnectionRoutingValue.test.tsx create mode 100644 src/components/connectionSelection/read/__tests__/PaymentMethodValue.test.tsx create mode 100644 src/components/connectionSelection/read/__tests__/useConnectionRoutingGridItems.test.tsx create mode 100644 src/components/connectionSelection/read/useConnectionRoutingGridItems.tsx create mode 100644 src/components/wallets/WalletExternalApps.tsx create mode 100644 src/components/wallets/__tests__/WalletExternalApps.test.tsx diff --git a/src/components/additionalIntegrationSettings/AdditionalIntegrationSettingsSelector.tsx b/src/components/additionalIntegrationSettings/AdditionalIntegrationSettingsSelector.tsx index 9590452e3d..d6e19905ba 100644 --- a/src/components/additionalIntegrationSettings/AdditionalIntegrationSettingsSelector.tsx +++ b/src/components/additionalIntegrationSettings/AdditionalIntegrationSettingsSelector.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' + import { ConnectionBehavior, deriveConnectionBehavior, @@ -26,6 +28,7 @@ interface AdditionalIntegrationSettingsSelectorProps { customerId: string values: AdditionalIntegrationSettingsValues onChange: (values: AdditionalIntegrationSettingsValues) => void + autoOpen?: boolean 'data-test'?: string } @@ -33,6 +36,7 @@ export const AdditionalIntegrationSettingsSelector = ({ customerId, values, onChange, + autoOpen = false, 'data-test': dataTest = ADDITIONAL_INTEGRATION_SETTINGS_SELECTOR_TEST_ID, }: AdditionalIntegrationSettingsSelectorProps) => { const { translate } = useInternationalization() @@ -41,6 +45,16 @@ export const AdditionalIntegrationSettingsSelector = ({ onSave: onChange, }) + const hasAutoOpened = useRef(false) + + useEffect(() => { + if (!autoOpen || hasAutoOpened.current) return + + hasAutoOpened.current = true + openDrawer(values) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoOpen]) + const getSubtitle = (): string => { const segments = ADDITIONAL_INTEGRATION_CATEGORIES.reduce((acc, category) => { const behavior = deriveConnectionBehavior(values[category]) diff --git a/src/components/additionalIntegrationSettings/__tests__/AdditionalIntegrationSettingsDrawer.test.tsx b/src/components/additionalIntegrationSettings/__tests__/AdditionalIntegrationSettingsDrawer.test.tsx index 08a103679d..ca4b003cbb 100644 --- a/src/components/additionalIntegrationSettings/__tests__/AdditionalIntegrationSettingsDrawer.test.tsx +++ b/src/components/additionalIntegrationSettings/__tests__/AdditionalIntegrationSettingsDrawer.test.tsx @@ -158,6 +158,40 @@ describe('AdditionalIntegrationSettingsSelector', () => { } }) + describe('GIVEN the details view asks for the drawer to open on landing', () => { + const EMPTY_VALUES = { + [ConnectionCategory.Accounting]: undefined, + [ConnectionCategory.Crm]: undefined, + [ConnectionCategory.Tax]: undefined, + } + + describe('WHEN the selector mounts with autoOpen', () => { + it('THEN should open the drawer once', () => { + const { rerender } = render( + , + ) + + expect(mockOpen).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + expect(mockOpen).toHaveBeenCalledTimes(1) + }) + }) + }) + describe('GIVEN the selector is mounted', () => { describe('WHEN it renders', () => { it('THEN should display the entry card without opening the drawer', () => { diff --git a/src/components/connectionSelection/read/ConnectionCodeChip.tsx b/src/components/connectionSelection/read/ConnectionCodeChip.tsx new file mode 100644 index 0000000000..65ac832c23 --- /dev/null +++ b/src/components/connectionSelection/read/ConnectionCodeChip.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from 'react' + +import { Avatar } from '~/components/designSystem/Avatar' +import { Chip } from '~/components/designSystem/Chip' + +type ConnectionCodeChipProps = { + code: string + avatar?: ReactNode + 'data-test'?: string +} + +export const ConnectionCodeChip = ({ + code, + avatar, + 'data-test': dataTest, +}: ConnectionCodeChipProps): JSX.Element => ( + + {!!avatar && ( + + {avatar} + + )} + {code} + + } + /> +) diff --git a/src/components/connectionSelection/read/ConnectionRoutingValue.tsx b/src/components/connectionSelection/read/ConnectionRoutingValue.tsx new file mode 100644 index 0000000000..7b8f90a355 --- /dev/null +++ b/src/components/connectionSelection/read/ConnectionRoutingValue.tsx @@ -0,0 +1,150 @@ +import { ReactNode } from 'react' + +import { integrationAvatarMapping, paymentAvatarMapping } from '~/components/avatarMappings' +import { + ConnectionCategory, + IntegrationConnectionCategory, +} from '~/components/customerConnections/types' +import { Typography } from '~/components/designSystem/Typography' +import { ConnectionResolvedBehaviorEnum } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { useCustomerIntegrationConnections } from '~/hooks/customer/useCustomerIntegrationConnections' +import { useCustomerPaymentConnections } from '~/hooks/customer/useCustomerPaymentConnections' + +import { ConnectionCodeChip } from './ConnectionCodeChip' + +export const CONNECTION_ROUTING_CHIP_TEST_ID = 'connection-routing-chip' +export const CONNECTION_ROUTING_INHERITED_TEST_ID = 'connection-routing-inherited' +export const CONNECTION_ROUTING_SKIPPED_TEST_ID = 'connection-routing-skipped' +export const CONNECTION_ROUTING_UNRESOLVED_TEST_ID = 'connection-routing-unresolved' + +export type ConnectionRoutingDisplay = { + behavior: ConnectionResolvedBehaviorEnum + code?: string | null +} + +type ConnectionRoutingChipProps = { + routing?: ConnectionRoutingDisplay + avatar?: ReactNode +} + +const ConnectionRoutingChip = ({ routing, avatar }: ConnectionRoutingChipProps): JSX.Element => { + const { translate } = useInternationalization() + + if (routing?.behavior === ConnectionResolvedBehaviorEnum.Skip) { + return ( + + {translate('text_1789472252793x3dxbqu3x10')} + + ) + } + + if (!routing?.code) { + return ( + + {translate('text_1789382180711vi1jj3immjw')} + + ) + } + + return ( + + + + {routing.behavior === ConnectionResolvedBehaviorEnum.Inherit && ( + + {`(${translate('text_1789558944658bay5bstkcut')})`} + + )} + + ) +} + +const PaymentConnectionRoutingValue = ({ + customerId, + routing, +}: { + customerId?: string + routing?: ConnectionRoutingDisplay +}): JSX.Element => { + const { connections } = useCustomerPaymentConnections({ customerId, skip: !routing?.code }) + + const provider = connections.find((connection) => connection.code === routing?.code)?.provider + + return ( + + ) +} + +const IntegrationConnectionRoutingValue = ({ + category, + customerId, + routing, +}: { + category: IntegrationConnectionCategory + customerId?: string + routing?: ConnectionRoutingDisplay +}): JSX.Element => { + const { connections } = useCustomerIntegrationConnections({ + customerId, + category, + skip: !routing?.code, + }) + + const integrationType = connections.find( + (connection) => connection.code === routing?.code, + )?.integrationType + + return ( + + ) +} + +type ConnectionRoutingValueProps = { + category: ConnectionCategory + customerId?: string + routing?: ConnectionRoutingDisplay +} + +export const ConnectionRoutingValue = ({ + category, + customerId, + routing, +}: ConnectionRoutingValueProps): JSX.Element => { + if (category === ConnectionCategory.Payment) { + return + } + + return ( + + ) +} diff --git a/src/components/connectionSelection/read/ConnectionSettingsSections.tsx b/src/components/connectionSelection/read/ConnectionSettingsSections.tsx new file mode 100644 index 0000000000..91f333299a --- /dev/null +++ b/src/components/connectionSelection/read/ConnectionSettingsSections.tsx @@ -0,0 +1,135 @@ +import { ReactNode } from 'react' + +import { ConnectionCategory } from '~/components/customerConnections/types' +import { Typography } from '~/components/designSystem/Typography' +import { DetailsPage } from '~/components/layouts/DetailsPage' +import { SelectedPaymentMethod } from '~/components/paymentMethodSelection/types' +import { ConnectionCategoryEnum } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' + +import { ConnectionRoutingDisplay } from './ConnectionRoutingValue' +import { PaymentMethodValue } from './PaymentMethodValue' +import { useConnectionRoutingGridItems } from './useConnectionRoutingGridItems' + +export const CONNECTION_SETTINGS_PAYMENT_SECTION_TEST_ID = 'connection-settings-payment-section' +export const CONNECTION_SETTINGS_ADDITIONAL_SECTION_TEST_ID = + 'connection-settings-additional-section' + +const ADDITIONAL_CATEGORIES = [ + ConnectionCategory.Tax, + ConnectionCategory.Accounting, + ConnectionCategory.Crm, +] + +type GridItem = { + label: string + value: ReactNode +} + +type SectionHeaderProps = { + title: string + description: string + action?: ReactNode +} + +const SectionHeader = ({ title, description, action }: SectionHeaderProps) => ( +
+
+ + {title} + + + + {description} + +
+ + {!!action && action} +
+) + +type ConnectionSettingsSectionsProps = { + connections?: (ConnectionRoutingDisplay & { category: ConnectionCategoryEnum })[] | null + customerId?: string + externalCustomerId?: string + selectedPaymentMethod?: SelectedPaymentMethod + paymentDescription: string + additionalDescription: string + paymentAction?: ReactNode + additionalAction?: ReactNode + extraPaymentItems?: GridItem[] +} + +export const ConnectionSettingsSections = ({ + connections, + customerId, + externalCustomerId, + selectedPaymentMethod, + paymentDescription, + additionalDescription, + paymentAction, + additionalAction, + extraPaymentItems = [], +}: ConnectionSettingsSectionsProps) => { + const { translate } = useInternationalization() + + const paymentConnectionItems = useConnectionRoutingGridItems({ + categories: [ConnectionCategory.Payment], + connections, + customerId, + }) + + const additionalConnectionItems = useConnectionRoutingGridItems({ + categories: ADDITIONAL_CATEGORIES, + connections, + customerId, + }) + + return ( + <> +
+ + + + ), + }, + ...extraPaymentItems, + ]} + /> +
+ +
+ + +
+ {additionalConnectionItems.map((item) => ( + + ))} +
+
+ + ) +} diff --git a/src/components/connectionSelection/read/PaymentMethodValue.tsx b/src/components/connectionSelection/read/PaymentMethodValue.tsx new file mode 100644 index 0000000000..258192b5cd --- /dev/null +++ b/src/components/connectionSelection/read/PaymentMethodValue.tsx @@ -0,0 +1,48 @@ +import { Chip } from '~/components/designSystem/Chip' +import { Typography } from '~/components/designSystem/Typography' +import { SelectedPaymentMethod } from '~/components/paymentMethodSelection/types' +import { useResolvedPaymentMethodDisplay } from '~/components/paymentMethodSelection/useResolvedPaymentMethodDisplay' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { usePaymentMethodsList } from '~/hooks/customer/usePaymentMethodsList' + +export const PAYMENT_METHOD_VALUE_CHIP_TEST_ID = 'payment-method-value-chip' +export const PAYMENT_METHOD_VALUE_INHERITED_TEST_ID = 'payment-method-value-inherited' + +type PaymentMethodValueProps = { + selectedPaymentMethod?: SelectedPaymentMethod + externalCustomerId?: string +} + +export const PaymentMethodValue = ({ + selectedPaymentMethod, + externalCustomerId, +}: PaymentMethodValueProps): JSX.Element => { + const { translate } = useInternationalization() + + const { data: paymentMethodsList } = usePaymentMethodsList({ + externalCustomerId: externalCustomerId || '', + withDeleted: false, + }) + + const { isInherited, label } = useResolvedPaymentMethodDisplay( + selectedPaymentMethod, + paymentMethodsList, + ) + + return ( + + + + {isInherited && ( + + {`(${translate('text_1789558944658bay5bstkcut')})`} + + )} + + ) +} diff --git a/src/components/connectionSelection/read/__tests__/ConnectionRoutingValue.test.tsx b/src/components/connectionSelection/read/__tests__/ConnectionRoutingValue.test.tsx new file mode 100644 index 0000000000..34eecfd0f3 --- /dev/null +++ b/src/components/connectionSelection/read/__tests__/ConnectionRoutingValue.test.tsx @@ -0,0 +1,175 @@ +import { screen } from '@testing-library/react' + +import { ConnectionCategory } from '~/components/customerConnections/types' +import { + ConnectionResolvedBehaviorEnum, + IntegrationTypeEnum, + ProviderTypeEnum, +} from '~/generated/graphql' +import { render } from '~/test-utils' + +import { + CONNECTION_ROUTING_CHIP_TEST_ID, + CONNECTION_ROUTING_INHERITED_TEST_ID, + CONNECTION_ROUTING_SKIPPED_TEST_ID, + CONNECTION_ROUTING_UNRESOLVED_TEST_ID, + ConnectionRoutingValue, +} from '../ConnectionRoutingValue' + +jest.mock('~/hooks/core/useInternationalization', () => ({ + useInternationalization: () => ({ translate: (key: string) => key }), +})) + +const mockUseCustomerPaymentConnections = jest.fn() +const mockUseCustomerIntegrationConnections = jest.fn() + +jest.mock('~/hooks/customer/useCustomerPaymentConnections', () => ({ + useCustomerPaymentConnections: (args: Record) => + mockUseCustomerPaymentConnections(args), +})) + +jest.mock('~/hooks/customer/useCustomerIntegrationConnections', () => ({ + useCustomerIntegrationConnections: (args: Record) => + mockUseCustomerIntegrationConnections(args), +})) + +describe('ConnectionRoutingValue', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockUseCustomerPaymentConnections.mockReturnValue({ + connections: [ + { id: 'pc-1', code: 'stripe-eu', name: 'Stripe EU', provider: ProviderTypeEnum.Stripe }, + ], + options: [], + defaultConnection: undefined, + isDefaultManual: false, + loading: false, + }) + + mockUseCustomerIntegrationConnections.mockReturnValue({ + connections: [ + { + id: 'ic-1', + code: 'anrok-eu', + name: 'Anrok EU', + group: '', + integrationType: IntegrationTypeEnum.Anrok, + isDefault: false, + }, + ], + options: [], + defaultConnection: undefined, + loading: false, + }) + }) + + describe('GIVEN a routing explicitly set on the billing object', () => { + describe('WHEN the behavior is specific', () => { + it('THEN should show the connection code without the inherited suffix', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_CHIP_TEST_ID)).toHaveTextContent('stripe-eu') + expect(screen.queryByTestId(CONNECTION_ROUTING_INHERITED_TEST_ID)).not.toBeInTheDocument() + }) + }) + + describe('WHEN the behavior is skip', () => { + it('THEN should show the skipped state instead of a chip', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_SKIPPED_TEST_ID)).toBeInTheDocument() + expect(screen.queryByTestId(CONNECTION_ROUTING_CHIP_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) + + describe('GIVEN a routing inherited from the customer', () => { + describe('WHEN the customer default resolves to a code', () => { + it('THEN should show the code with the inherited suffix', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_CHIP_TEST_ID)).toHaveTextContent('anrok-eu') + expect(screen.getByTestId(CONNECTION_ROUTING_INHERITED_TEST_ID)).toBeInTheDocument() + }) + }) + + describe('WHEN the customer has no default', () => { + it('THEN should show the unresolved state', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_UNRESOLVED_TEST_ID)).toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the category has no routing at all', () => { + describe('WHEN nothing resolves', () => { + it('THEN should show the unresolved state', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_UNRESOLVED_TEST_ID)).toBeInTheDocument() + expect(screen.queryByTestId(CONNECTION_ROUTING_CHIP_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the connection list is only needed to resolve a provider avatar', () => { + describe('WHEN the routing carries no code', () => { + it.each([ + ['payment', ConnectionCategory.Payment, () => mockUseCustomerPaymentConnections], + ['integration', ConnectionCategory.Tax, () => mockUseCustomerIntegrationConnections], + ])('THEN should skip the %s connections query', (_, category, getMock) => { + render() + + expect(getMock()).toHaveBeenCalledWith(expect.objectContaining({ skip: true })) + }) + }) + }) + + describe('GIVEN an integration category', () => { + describe('WHEN the value renders', () => { + it('THEN should query the customer connections of that category', () => { + render( + , + ) + + expect(mockUseCustomerIntegrationConnections).toHaveBeenCalledWith({ + customerId: 'customer-1', + category: ConnectionCategory.Tax, + skip: false, + }) + }) + }) + }) +}) diff --git a/src/components/connectionSelection/read/__tests__/PaymentMethodValue.test.tsx b/src/components/connectionSelection/read/__tests__/PaymentMethodValue.test.tsx new file mode 100644 index 0000000000..dec38ba3d3 --- /dev/null +++ b/src/components/connectionSelection/read/__tests__/PaymentMethodValue.test.tsx @@ -0,0 +1,99 @@ +import { screen } from '@testing-library/react' + +import { PaymentMethodTypeEnum } from '~/generated/graphql' +import { createMockPaymentMethod } from '~/hooks/customer/__tests__/factories/PaymentMethod.factory' +import { PaymentMethodItem } from '~/hooks/customer/usePaymentMethodsList' +import { render } from '~/test-utils' + +import { + PAYMENT_METHOD_VALUE_CHIP_TEST_ID, + PAYMENT_METHOD_VALUE_INHERITED_TEST_ID, + PaymentMethodValue, +} from '../PaymentMethodValue' + +let mockPaymentMethodsList: PaymentMethodItem[] = [] + +jest.mock('~/hooks/core/useInternationalization', () => ({ + useInternationalization: () => ({ translate: (key: string) => key }), +})) + +jest.mock('~/hooks/useOrganizationInfos', () => ({ + useOrganizationInfos: () => ({ + organization: { defaultCurrency: 'USD' }, + intlFormatDateTimeOrgaTZ: () => ({ date: '2024-01-01' }), + hasFeatureFlag: () => true, + }), +})) + +jest.mock('~/hooks/customer/usePaymentMethodsList', () => ({ + usePaymentMethodsList: () => ({ + data: mockPaymentMethodsList, + loading: false, + error: false, + refetch: jest.fn(), + }), +})) + +describe('PaymentMethodValue', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPaymentMethodsList = [] + }) + + describe('GIVEN the object selects a specific provider method', () => { + describe('WHEN it renders', () => { + it('THEN should show the formatted method as a chip without the customer-default label', () => { + const paymentMethod = createMockPaymentMethod() + + mockPaymentMethodsList = [paymentMethod] + + render( + , + ) + + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_CHIP_TEST_ID)).toHaveTextContent('4242') + expect(screen.queryByTestId(PAYMENT_METHOD_VALUE_INHERITED_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the object falls back to the customer default', () => { + describe('WHEN it renders', () => { + it('THEN should show the customer-default label next to the chip', () => { + mockPaymentMethodsList = [createMockPaymentMethod({ isDefault: true })] + + render( + , + ) + + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_CHIP_TEST_ID)).toBeInTheDocument() + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_INHERITED_TEST_ID)).toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the object is paid manually', () => { + describe('WHEN it renders', () => { + it('THEN should show the manual label as a chip', () => { + render( + , + ) + + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_CHIP_TEST_ID)).toBeInTheDocument() + expect(screen.queryByTestId(PAYMENT_METHOD_VALUE_INHERITED_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/components/connectionSelection/read/__tests__/useConnectionRoutingGridItems.test.tsx b/src/components/connectionSelection/read/__tests__/useConnectionRoutingGridItems.test.tsx new file mode 100644 index 0000000000..801b5cb33a --- /dev/null +++ b/src/components/connectionSelection/read/__tests__/useConnectionRoutingGridItems.test.tsx @@ -0,0 +1,105 @@ +import { renderHook } from '@testing-library/react' + +import { ConnectionCategory } from '~/components/customerConnections/types' +import { ConnectionCategoryEnum, ConnectionResolvedBehaviorEnum } from '~/generated/graphql' +import { render } from '~/test-utils' + +import { + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS, + useConnectionRoutingGridItems, +} from '../useConnectionRoutingGridItems' + +jest.mock('~/hooks/core/useInternationalization', () => ({ + useInternationalization: () => ({ translate: (key: string) => key }), +})) + +const mockConnectionRoutingValue = jest.fn() + +jest.mock('../ConnectionRoutingValue', () => ({ + ConnectionRoutingValue: (props: Record) => { + mockConnectionRoutingValue(props) + + return null + }, +})) + +const CONNECTIONS = [ + { + category: ConnectionCategoryEnum.Payment, + behavior: ConnectionResolvedBehaviorEnum.Specific, + code: 'stripe-eu', + }, + { + category: ConnectionCategoryEnum.Tax, + behavior: ConnectionResolvedBehaviorEnum.Inherit, + code: 'anrok-eu', + }, +] + +const renderItems = (categories: ConnectionCategory[]) => + renderHook(() => + useConnectionRoutingGridItems({ + categories, + connections: CONNECTIONS, + customerId: 'customer-1', + }), + ) + +describe('useConnectionRoutingGridItems', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('GIVEN a list of categories', () => { + describe('WHEN the items are built', () => { + it('THEN should return one labeled row per category, in order', () => { + const { result } = renderItems([ + ConnectionCategory.Payment, + ConnectionCategory.Tax, + ConnectionCategory.Accounting, + ConnectionCategory.Crm, + ]) + + expect(result.current.map(({ label }) => label)).toEqual([ + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[ConnectionCategory.Payment], + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[ConnectionCategory.Tax], + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[ConnectionCategory.Accounting], + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[ConnectionCategory.Crm], + ]) + }) + }) + }) + + describe('GIVEN the billing object routes some categories', () => { + describe('WHEN a category has a routing', () => { + it('THEN should hand that routing to the value', () => { + const { result } = renderItems([ConnectionCategory.Tax]) + + render(<>{result.current[0].value}) + + expect(mockConnectionRoutingValue).toHaveBeenCalledWith( + expect.objectContaining({ + category: ConnectionCategory.Tax, + customerId: 'customer-1', + routing: expect.objectContaining({ + behavior: ConnectionResolvedBehaviorEnum.Inherit, + code: 'anrok-eu', + }), + }), + ) + }) + }) + + describe('WHEN a category is missing from the connections', () => { + it('THEN should hand an undefined routing to the value', () => { + const { result } = renderItems([ConnectionCategory.Crm]) + + render(<>{result.current[0].value}) + + expect(mockConnectionRoutingValue).toHaveBeenCalledWith( + expect.objectContaining({ category: ConnectionCategory.Crm, routing: undefined }), + ) + }) + }) + }) +}) diff --git a/src/components/connectionSelection/read/useConnectionRoutingGridItems.tsx b/src/components/connectionSelection/read/useConnectionRoutingGridItems.tsx new file mode 100644 index 0000000000..6fe495fa28 --- /dev/null +++ b/src/components/connectionSelection/read/useConnectionRoutingGridItems.tsx @@ -0,0 +1,56 @@ +import { ReactNode } from 'react' + +import { findConnectionRouting } from '~/components/connectionSelection/fromConnectionRouting' +import { ConnectionCategory } from '~/components/customerConnections/types' +import { ConnectionCategoryEnum } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' + +import { ConnectionRoutingDisplay, ConnectionRoutingValue } from './ConnectionRoutingValue' + +export const CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS: Record = { + [ConnectionCategory.Payment]: 'text_1789557972392jhb3cywzp35', + [ConnectionCategory.Tax]: 'text_1789557972392kzvs8grsxor', + [ConnectionCategory.Accounting]: 'text_1789557972392ui25nl8slm3', + [ConnectionCategory.Crm]: 'text_1728658962985xpfdvl5ru8a', +} + +const CONNECTION_CATEGORY_TO_API_CATEGORY: Record = { + [ConnectionCategory.Payment]: ConnectionCategoryEnum.Payment, + [ConnectionCategory.Tax]: ConnectionCategoryEnum.Tax, + [ConnectionCategory.Accounting]: ConnectionCategoryEnum.Accounting, + [ConnectionCategory.Crm]: ConnectionCategoryEnum.Crm, +} + +type ConnectionRoutingRow = ConnectionRoutingDisplay & { + category: ConnectionCategoryEnum +} + +type ConnectionRoutingGridItem = { + label: string + value: ReactNode +} + +interface UseConnectionRoutingGridItemsArgs { + categories: ConnectionCategory[] + connections?: ConnectionRoutingRow[] | null + customerId?: string +} + +export const useConnectionRoutingGridItems = ({ + categories, + connections, + customerId, +}: UseConnectionRoutingGridItemsArgs): ConnectionRoutingGridItem[] => { + const { translate } = useInternationalization() + + return categories.map((category) => ({ + label: translate(CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[category]), + value: ( + + ), + })) +} diff --git a/src/components/paymentSettings/connectionFirst/ConnectionPaymentSettingsSelector.tsx b/src/components/paymentSettings/connectionFirst/ConnectionPaymentSettingsSelector.tsx index 0ad88f3db4..78d44de388 100644 --- a/src/components/paymentSettings/connectionFirst/ConnectionPaymentSettingsSelector.tsx +++ b/src/components/paymentSettings/connectionFirst/ConnectionPaymentSettingsSelector.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' + import { ConnectionBehavior, deriveConnectionBehavior, @@ -28,6 +30,7 @@ interface ConnectionPaymentSettingsSelectorProps { connection: SelectedConnection paymentMethod: SelectedPaymentMethod onChange: (values: ConnectionPaymentSettingsValues) => void + autoOpen?: boolean 'data-test'?: string } @@ -52,6 +55,7 @@ export const ConnectionPaymentSettingsSelector = ({ connection, paymentMethod, onChange, + autoOpen = false, 'data-test': dataTest = CONNECTION_PAYMENT_SETTINGS_SELECTOR_TEST_ID, }: ConnectionPaymentSettingsSelectorProps) => { const { translate } = useInternationalization() @@ -64,6 +68,16 @@ export const ConnectionPaymentSettingsSelector = ({ const seededConnection = seedConnection(connection, paymentMethod) + const hasAutoOpened = useRef(false) + + useEffect(() => { + if (!autoOpen || hasAutoOpened.current) return + + hasAutoOpened.current = true + openDrawer({ connection: seededConnection, paymentMethod }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoOpen]) + return ( { mockPaymentMethodFieldsProps.current = null }) + describe('GIVEN the details view asks for the drawer to open on landing', () => { + describe('WHEN the selector mounts with autoOpen', () => { + it('THEN should open the drawer once, seeded with the current values', () => { + const { rerender } = render( + , + ) + + expect(mockOpen).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + expect(mockOpen).toHaveBeenCalledTimes(1) + }) + }) + + describe('WHEN the selector mounts without autoOpen', () => { + it('THEN should not open the drawer', () => { + render( + , + ) + + expect(mockOpen).not.toHaveBeenCalled() + }) + }) + }) + describe('GIVEN the selector is mounted', () => { describe('WHEN it renders', () => { it('THEN should display the entry card without opening the drawer', () => { diff --git a/src/components/wallets/WalletExternalApps.tsx b/src/components/wallets/WalletExternalApps.tsx new file mode 100644 index 0000000000..a48d4b7cb1 --- /dev/null +++ b/src/components/wallets/WalletExternalApps.tsx @@ -0,0 +1,77 @@ +import { ReactNode } from 'react' +import { generatePath } from 'react-router' + +import { ConnectionSettingsSections } from '~/components/connectionSelection/read/ConnectionSettingsSections' +import { ButtonLink } from '~/components/designSystem/ButtonLink' +import { EDIT_WALLET_ROUTE } from '~/core/router' +import { WalletDetailsFragment } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' + +export const WALLET_EXTERNAL_APPS_CONTAINER_TEST_ID = 'wallet-external-apps-container' +export const WALLET_EXTERNAL_APPS_EDIT_PAYMENT_TEST_ID = 'wallet-external-apps-edit-payment' +export const WALLET_EXTERNAL_APPS_EDIT_ADDITIONAL_TEST_ID = 'wallet-external-apps-edit-additional' + +type WalletExternalAppsProps = { + wallet?: WalletDetailsFragment | null + walletId?: string + customerId?: string + canEditWallet: boolean +} + +const WalletExternalApps = ({ + wallet, + walletId, + customerId, + canEditWallet, +}: WalletExternalAppsProps) => { + const { translate } = useInternationalization() + + if (!wallet) { + return + } + + const renderEditLink = ( + routerState: Record, + dataTest: string, + ): ReactNode | null => { + if (!canEditWallet || !walletId || !customerId) return null + + return ( + + {translate('text_62e161ceb87c201025388aa2')} + + ) + } + + return ( +
+ +
+ ) +} + +export default WalletExternalApps diff --git a/src/components/wallets/WalletInformations.tsx b/src/components/wallets/WalletInformations.tsx index 382b1f38c3..db72b73eeb 100644 --- a/src/components/wallets/WalletInformations.tsx +++ b/src/components/wallets/WalletInformations.tsx @@ -9,7 +9,7 @@ import { useResolvedPaymentMethodValue } from '~/components/paymentMethodSelecti import { ViewTypeEnum } from '~/core/constants/billingObjectViewTypes' import { intlFormatNumber } from '~/core/formats/intlFormatNumber' import { deserializeAmount, getCurrencyPrecision } from '~/core/serializers/serializeAmount' -import { CurrencyEnum, WalletDetailsFragment } from '~/generated/graphql' +import { CurrencyEnum, FeatureFlagEnum, WalletDetailsFragment } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { usePaymentMethodsList } from '~/hooks/customer/usePaymentMethodsList' import { useCustomerInvoiceCustomSections } from '~/hooks/useCustomerInvoiceCustomSections' @@ -17,6 +17,7 @@ import { useOrganizationInfos } from '~/hooks/useOrganizationInfos' import { tw } from '~/styles/utils' export const WALLET_INFORMATIONS_CONTAINER_TEST_ID = 'wallet-informations-container' +export const WALLET_INFORMATIONS_PAYMENT_SECTION_TEST_ID = 'wallet-informations-payment-section' type WalletInformationsProps = { wallet?: WalletDetailsFragment | null @@ -36,8 +37,11 @@ const SectionTitle = ({ title, subtitle }: { title: string; subtitle: string }) const WalletInformations = ({ wallet }: WalletInformationsProps) => { const { translate } = useInternationalization() - const { intlFormatDateTimeOrgaTZ, organization: { defaultCurrency } = {} } = - useOrganizationInfos() + const { + intlFormatDateTimeOrgaTZ, + hasFeatureFlag, + organization: { defaultCurrency } = {}, + } = useOrganizationInfos() const { data: paymentMethodsList } = usePaymentMethodsList({ externalCustomerId: wallet?.customer?.externalId || '', @@ -86,7 +90,10 @@ const WalletInformations = ({ wallet }: WalletInformationsProps) => { const showAppliesToSection = !!wallet?.appliesTo?.feeTypes?.length || !!wallet?.appliesTo?.billableMetrics?.length - const showPaymentSection = paymentMethodValue !== '-' || showWalletInvoiceCustomSectionsRow + // With multi_connection on this row lives on the External apps tab (WalletExternalApps). + const showPaymentMethodRow = + !hasFeatureFlag(FeatureFlagEnum.MultiConnection) && paymentMethodValue !== '-' + const showPaymentSection = showPaymentMethodRow || showWalletInvoiceCustomSectionsRow return (
@@ -206,7 +213,10 @@ const WalletInformations = ({ wallet }: WalletInformationsProps) => { )} {showPaymentSection && ( -
+
{ `wallet-recurring-rules-rule-${index}` const WALLET_RECURRING_RULES_TOPUP_TYPE_TEST_ID = 'wallet-recurring-rules-topup-type' +export const WALLET_RECURRING_RULES_EDIT_PAYMENT_TEST_ID = (index: number) => + `wallet-recurring-rules-edit-payment-${index}` +export const WALLET_RECURRING_RULES_EDIT_ADDITIONAL_TEST_ID = (index: number) => + `wallet-recurring-rules-edit-additional-${index}` + const YES_TRANSLATION_KEY = 'text_1764160009979jzn4xunn1z8' const NO_TRANSLATION_KEY = 'text_176416000997957yqelmt2m2' @@ -44,17 +55,23 @@ const SectionTitle = ({ title, subtitle }: { title: string; subtitle: string }) const RecurringRuleBlock = ({ rule, + ruleIndex, wallet, paymentMethodsList, customerIcsData, + canEditWallet, }: { rule: WalletRecurringRule + ruleIndex: number wallet: WalletDetailsFragment paymentMethodsList?: PaymentMethodList customerIcsData: CustomerIcsData + canEditWallet: boolean }) => { const { translate } = useInternationalization() - const { intlFormatDateTimeOrgaTZ } = useOrganizationInfos() + const { intlFormatDateTimeOrgaTZ, hasFeatureFlag } = useOrganizationInfos() + + const isMultiConnectionEnabled = hasFeatureFlag(FeatureFlagEnum.MultiConnection) const paymentMethodValue = useResolvedPaymentMethodValue( { @@ -107,7 +124,31 @@ const RecurringRuleBlock = ({ customerIcsData, }) - const showPaymentSection = paymentMethodValue !== '-' || showInvoiceCustomSectionsRow + const showPaymentSection = + isMultiConnectionEnabled || paymentMethodValue !== '-' || showInvoiceCustomSectionsRow + + const renderPaymentSection = () => { + if (isMultiConnectionEnabled) { + return ( + + ) + } + + return ( + + ) + } return ( <> @@ -226,18 +267,78 @@ const RecurringRuleBlock = ({ )}
- {showPaymentSection && ( - - )} + {showPaymentSection && renderPaymentSection()} ) } +const RecurringRuleConnectionSections = ({ + rule, + ruleIndex, + wallet, + showInvoiceCustomSectionsRow, + canEditWallet, +}: { + rule: WalletRecurringRule + ruleIndex: number + wallet: WalletDetailsFragment + showInvoiceCustomSectionsRow: boolean + canEditWallet: boolean +}) => { + const { translate } = useInternationalization() + + const customerId = wallet.customer?.id + + const renderEditLink = (dataTest: string): React.ReactNode => { + if (!canEditWallet || !customerId) return null + + return ( + + {translate('text_62e161ceb87c201025388aa2')} + + ) + } + + return ( + + ), + }, + ] + : [] + } + /> + ) +} + // Same section as the wallet-level one on the overview (WalletInformations), // scoped to the rule's own payment method / invoice custom sections. const RecurringRulePaymentSection = ({ @@ -290,6 +391,7 @@ const RecurringRulePaymentSection = ({ type WalletRecurringRulesProps = { wallet?: WalletDetailsFragment | null + canEditWallet?: boolean } /** @@ -297,7 +399,7 @@ type WalletRecurringRulesProps = { * Editing goes through the wallet edition form — the rules have no dedicated * mutation, they only travel nested in updateCustomerWallet. */ -const WalletRecurringRules = ({ wallet }: WalletRecurringRulesProps) => { +const WalletRecurringRules = ({ wallet, canEditWallet = false }: WalletRecurringRulesProps) => { const { translate } = useInternationalization() const { isPremium } = useCurrentUser() @@ -351,9 +453,11 @@ const WalletRecurringRules = ({ wallet }: WalletRecurringRulesProps) => {
))} diff --git a/src/components/wallets/__tests__/WalletExternalApps.test.tsx b/src/components/wallets/__tests__/WalletExternalApps.test.tsx new file mode 100644 index 0000000000..872f99f990 --- /dev/null +++ b/src/components/wallets/__tests__/WalletExternalApps.test.tsx @@ -0,0 +1,223 @@ +import { screen } from '@testing-library/react' + +import { ButtonLinkBaseProps } from '~/components/designSystem/ButtonLink' +import { + ConnectionCategoryEnum, + ConnectionResolvedBehaviorEnum, + PaymentMethodTypeEnum, + WalletDetailsFragment, +} from '~/generated/graphql' +import { createMockPaymentMethod } from '~/hooks/customer/__tests__/factories/PaymentMethod.factory' +import { PaymentMethodItem } from '~/hooks/customer/usePaymentMethodsList' +import { render } from '~/test-utils' + +import WalletExternalApps, { + WALLET_EXTERNAL_APPS_CONTAINER_TEST_ID, + WALLET_EXTERNAL_APPS_EDIT_ADDITIONAL_TEST_ID, + WALLET_EXTERNAL_APPS_EDIT_PAYMENT_TEST_ID, +} from '../WalletExternalApps' + +let mockPaymentMethodsList: PaymentMethodItem[] = [] + +jest.mock('~/hooks/core/useInternationalization', () => ({ + useInternationalization: () => ({ translate: (key: string) => key }), +})) + +jest.mock('~/hooks/useOrganizationInfos', () => ({ + useOrganizationInfos: () => ({ + organization: { defaultCurrency: 'USD' }, + intlFormatDateTimeOrgaTZ: () => ({ date: '2024-01-01' }), + hasFeatureFlag: () => true, + }), +})) + +jest.mock('~/hooks/customer/usePaymentMethodsList', () => ({ + usePaymentMethodsList: () => ({ + data: mockPaymentMethodsList, + loading: false, + error: false, + refetch: jest.fn(), + }), +})) + +const mockConnectionRoutingValue = jest.fn() + +jest.mock('~/components/connectionSelection/read/ConnectionRoutingValue', () => ({ + ConnectionRoutingValue: (props: Record) => { + mockConnectionRoutingValue(props) + + return null + }, +})) + +// routerState (the drawer auto-open intent) never reaches the DOM, so the Edit +// links can only be asserted through the props they receive. +const mockButtonLink = jest.fn() + +jest.mock('~/components/designSystem/ButtonLink', () => ({ + ButtonLink: (props: ButtonLinkBaseProps & { 'data-test'?: string }) => { + mockButtonLink(props) + + return