diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx index 42f0369936f..8484dccb449 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx @@ -57,7 +57,10 @@ export const BillingCustomerData = () => { isSuccess, } = useOrganizationCustomerProfileQuery( { slug }, - { enabled: canReadBillingCustomerData && inView } + { + enabled: canReadBillingCustomerData && inView, + select: (data) => (data ? { address: data.address, billing_name: data.billing_name } : data), + } ) const { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx new file mode 100644 index 00000000000..83f8d59289d --- /dev/null +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx @@ -0,0 +1,252 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { QueryClient } from '@tanstack/react-query' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { platformComponents as components } from 'api-types' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import BillingEmail from './BillingEmail' +import { organizationKeys } from '@/data/organizations/keys' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +type CustomerResponse = components['schemas']['CustomerResponse'] + +// The additional-emails control renders a Radix Popover when adding a recipient. +mockAnimationsApi() + +const SLUG = 'acme-org' + +const mockCheckPermissions = vi.hoisted(() => vi.fn()) + +vi.mock('common', async (importOriginal) => { + const original = (await importOriginal()) as typeof import('common') + return { + ...original, + // The customer-profile query is platform-only; IS_PLATFORM is false by default in tests. + IS_PLATFORM: true, + useParams: () => ({ slug: SLUG }), + } +}) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: (action: string) => mockCheckPermissions(action), +})) + +vi.mock('react-intersection-observer', () => ({ + useInView: () => ({ ref: vi.fn(), inView: true }), +})) + +const createCustomerProfileResponse = ( + overrides: Partial = {} +): CustomerResponse => ({ + additional_emails: [], + balance: 0, + billing_via_partner: false, + email: 'billing@example.com', + tax_id: null, + ...overrides, +}) + +const mockCustomerProfile = (overrides: Partial = {}) => { + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/customer', + response: () => HttpResponse.json(createCustomerProfileResponse(overrides)), + }) +} + +const mockUpdateCustomerProfile = () => { + const requests: Array<{ slug: string | undefined; body: unknown }> = [] + addAPIMock({ + method: 'put', + path: '/platform/organizations/:slug/customer', + response: async ({ request, params }) => { + requests.push({ slug: params.slug as string | undefined, body: await request.json() }) + return HttpResponse.json({}, { status: 204 }) + }, + }) + return requests +} + +const addRecipient = async (email: string) => { + const input = screen.getByPlaceholderText('Add additional recipients') + await userEvent.click(input) + await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'true')) + // fireEvent.change (rather than userEvent.type) avoids racing the popover's open-state + // transition character-by-character, which otherwise drops the first keystroke(s). + fireEvent.change(input, { target: { value: email } }) + fireEvent.click(await screen.findByRole('option', { name: new RegExp(`Create "${email}"`) })) +} + +const removeRecipient = (email: string) => { + const badge = screen.getByText(email) + fireEvent.click(badge.querySelector('svg')!.parentElement!) +} + +describe('BillingEmail', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckPermissions.mockImplementation(() => ({ can: true, isSuccess: true })) + }) + + test('renders the current billing email and additional recipients', async () => { + mockCustomerProfile({ + email: 'billing@example.com', + additional_emails: ['cc@example.com'], + }) + + customRender() + + expect(await screen.findByPlaceholderText('Email')).toHaveValue('billing@example.com') + expect(screen.getByText('cc@example.com')).toBeInTheDocument() + }) + + test('shows a permission notice when the user cannot read billing data', async () => { + mockCheckPermissions.mockImplementation((action: string) => ({ + can: action !== PermissionAction.BILLING_READ, + isSuccess: true, + })) + + customRender() + + expect(await screen.findByText(/view this organization's email recipients/)).toBeInTheDocument() + expect(screen.queryByPlaceholderText('Email')).not.toBeInTheDocument() + }) + + test('disables the email controls when the user cannot update billing data', async () => { + mockCheckPermissions.mockImplementation((action: string) => ({ + can: action !== PermissionAction.BILLING_WRITE, + isSuccess: true, + })) + mockCustomerProfile({ email: 'billing@example.com', additional_emails: [] }) + + customRender() + + expect(await screen.findByPlaceholderText('Email')).toBeDisabled() + expect(await screen.findByPlaceholderText('Add additional recipients')).toBeDisabled() + expect( + screen.getByText('You need additional permissions to update billing emails') + ).toBeInTheDocument() + }) + + test('saves the updated email while keeping the existing additional recipients', async () => { + mockCustomerProfile({ + email: 'billing@example.com', + additional_emails: ['cc@example.com'], + }) + const requests = mockUpdateCustomerProfile() + + customRender() + + const emailInput = await screen.findByPlaceholderText('Email') + await userEvent.clear(emailInput) + await userEvent.type(emailInput, 'new-billing@example.com') + + fireEvent.click(await screen.findByRole('button', { name: 'Save' })) + + await waitFor(() => expect(requests).toHaveLength(1)) + expect(requests[0]).toEqual({ + slug: SLUG, + body: { email: 'new-billing@example.com', additional_emails: ['cc@example.com'] }, + }) + }) + + test('adds a recipient when there are none', async () => { + mockCustomerProfile({ email: 'billing@example.com', additional_emails: [] }) + const requests = mockUpdateCustomerProfile() + + customRender() + await screen.findByPlaceholderText('Email') + + await addRecipient('new@example.com') + fireEvent.click(await screen.findByRole('button', { name: 'Save' })) + + await waitFor(() => expect(requests).toHaveLength(1)) + expect(requests[0]).toEqual({ + slug: SLUG, + body: { email: 'billing@example.com', additional_emails: ['new@example.com'] }, + }) + }) + + test('removes a recipient when there is one', async () => { + mockCustomerProfile({ email: 'billing@example.com', additional_emails: ['cc@example.com'] }) + const requests = mockUpdateCustomerProfile() + + customRender() + await screen.findByText('cc@example.com') + + removeRecipient('cc@example.com') + fireEvent.click(await screen.findByRole('button', { name: 'Save' })) + + await waitFor(() => expect(requests).toHaveLength(1)) + expect(requests[0]).toEqual({ + slug: SLUG, + body: { email: 'billing@example.com', additional_emails: [] }, + }) + }) + + test('appends a recipient when there is already one', async () => { + mockCustomerProfile({ + email: 'billing@example.com', + additional_emails: ['existing@example.com'], + }) + const requests = mockUpdateCustomerProfile() + + customRender() + await screen.findByText('existing@example.com') + + await addRecipient('new@example.com') + fireEvent.click(await screen.findByRole('button', { name: 'Save' })) + + await waitFor(() => expect(requests).toHaveLength(1)) + expect(requests[0]).toEqual({ + slug: SLUG, + body: { + email: 'billing@example.com', + additional_emails: ['existing@example.com', 'new@example.com'], + }, + }) + }) + + test('keeps an in-progress edit when the profile data refetches', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + let getCallCount = 0 + let customerProfile = createCustomerProfileResponse({ + email: 'initial@example.com', + additional_emails: ['cc-initial@example.com'], + }) + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/customer', + response: () => { + getCallCount++ + return HttpResponse.json(customerProfile) + }, + }) + + customRender(, { queryClient }) + + const emailInput = await screen.findByPlaceholderText('Email') + await waitFor(() => expect(emailInput).toHaveValue('initial@example.com')) + + await userEvent.clear(emailInput) + await userEvent.type(emailInput, 'edited@example.com') + + // Simulate an unrelated refetch of the same shared customer-profile query (e.g. triggered + // by another section, or a background refetch) returning newer server data. + customerProfile = createCustomerProfileResponse({ + email: 'server-updated@example.com', + additional_emails: ['cc-updated@example.com'], + }) + await queryClient.invalidateQueries({ queryKey: organizationKeys.customerProfile(SLUG) }) + await waitFor(() => expect(getCallCount).toBeGreaterThanOrEqual(2)) + + // The dirty form is left untouched - not overwritten with the newly fetched data. + expect(emailInput).toHaveValue('edited@example.com') + expect(screen.getByText('cc-initial@example.com')).toBeInTheDocument() + expect(screen.queryByText('cc-updated@example.com')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index f2b7e943f76..a035f9a4650 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -27,9 +27,8 @@ import { FormPanel } from '@/components/ui/Forms/FormPanel' import { FormSection, FormSectionContent } from '@/components/ui/Forms/FormSection' import { NoPermission } from '@/components/ui/NoPermission' import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query' -import { useOrganizationUpdateMutation } from '@/data/organizations/organization-update-mutation' +import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' const FORM_ID = 'org-billing-email' const formSchema = z.object({ @@ -39,53 +38,56 @@ const formSchema = z.object({ const BillingEmail = () => { const { slug } = useParams() - const { data: selectedOrganization } = useSelectedOrganizationQuery() - - const { name, billing_email } = selectedOrganization ?? {} const { can: canReadBillingEmail, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, - 'stripe.subscriptions' + 'stripe.customer' ) - const { can: canUpdateOrganization } = useAsyncCheckPermissions( - PermissionAction.UPDATE, - 'organizations' + const { can: canUpdateBillingData } = useAsyncCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.customer' ) const { ref, inView } = useInView({ triggerOnce: true }) - const { data: billingCustomer, isPending: loadingBillingCustomer } = - useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingEmail && inView }) + const { data: customerProfile, isPending: loadingBillingCustomer } = + useOrganizationCustomerProfileQuery( + { slug }, + { + enabled: canReadBillingEmail && inView, + select: (data) => + data ? { email: data.email, additional_emails: data.additional_emails } : data, + } + ) const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { - billingEmail: billing_email ?? '', - additionalBillingEmails: billingCustomer?.additional_emails ?? [], + billingEmail: customerProfile?.email ?? '', + additionalBillingEmails: customerProfile?.additional_emails ?? [], }, }) const additionalBillingEmails = useWatch({ control: form.control, name: 'additionalBillingEmails', }) - const { errors } = form.formState + const { errors, isDirty } = form.formState const additionalEmailsError = errors.additionalBillingEmails ?? [] - const { mutate: updateOrganization, isPending: isUpdating } = useOrganizationUpdateMutation() + const { mutate: updateCustomerProfile, isPending: isUpdating } = + useOrganizationCustomerProfileUpdateMutation() const onUpdateOrganizationEmail = async (values: z.infer) => { - if (!canUpdateOrganization) { + if (!canUpdateBillingData) { return toast.error('You do not have the required permissions to update this organization') } if (!slug) return console.error('Slug is required') - if (!name) return console.error('Organization name is required') - updateOrganization( + updateCustomerProfile( { slug, - name, - billing_email: values.billingEmail, - additional_billing_emails: values.additionalBillingEmails, + email: values.billingEmail, + additional_emails: values.additionalBillingEmails, }, { onSuccess: () => { @@ -97,13 +99,13 @@ const BillingEmail = () => { } useEffect(() => { - if (billingCustomer) { + if (customerProfile && !isDirty) { form.reset({ - billingEmail: billing_email ?? '', - additionalBillingEmails: billingCustomer.additional_emails ?? [], + billingEmail: customerProfile.email ?? '', + additionalBillingEmails: customerProfile.additional_emails ?? [], }) } - }, [billingCustomer]) + }, [form, customerProfile, isDirty]) return ( @@ -127,11 +129,11 @@ const BillingEmail = () => { { type="email" {...field} placeholder="Email" - disabled={!canUpdateOrganization} + disabled={!canUpdateBillingData} /> @@ -177,7 +179,11 @@ const BillingEmail = () => { } > - + ({ mutationFn: (vars) => updateOrganizationCustomerProfile(vars), async onSuccess(data, variables, context) { - const { address, slug, billing_name, tax_id, dry_run } = variables + const { address, slug, billing_name, tax_id, email, additional_emails, dry_run } = variables if (dry_run) { await onSuccess?.(data, variables, context) return } - // Optimistically update the cache for immediate UI consistency + // Optimistically update the cache for immediate UI consistency. Only patch the fields + // that were actually part of this mutation's variables - each caller (e.g. BillingEmail, + // BillingCustomerData) only sends the subset it owns, so an unconditional overwrite here + // would wipe out the other fields in the shared cache entry. queryClient.setQueriesData( { queryKey: organizationKeys.customerProfile(slug) }, (prev: any) => { if (!prev) return prev return { ...prev, - billing_name, + ...(billing_name !== undefined ? { billing_name } : {}), ...(address !== undefined ? { address } : {}), + ...(email !== undefined ? { email } : {}), + ...(additional_emails !== undefined ? { additional_emails: additional_emails } : {}), } } )