From b2a216b617f1fb700961cfb11e4d4c852a9db819 Mon Sep 17 00:00:00 2001 From: Daniel Guerra <15204776+danielmx-dev@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:56:20 -0600 Subject: [PATCH] feat(billing): Use the customer data endpoint to update billing emails (#49160) ## What kind of change does this PR introduce? Change the update billing email component so it uses the update customer endpoint instead of the update org endpoint. This allows users that have the BILLING_WRITE permission to use the endpoint to update relevant organization data, while keeping the restrictions of the update organization endpoint that allow updating other values (e.g. the org name). This change requires an update in the Update Customer endpoint to support billing email updates. Do not merge until that is deployed. ## What is the current behavior? - Admins are not allowed to update the billing emails of an organization. - The update organization endpoint (`PATCH /platform/organizations/{slug}/`) is used to update the billing email details. ## What is the new behavior? - Both admin and owners are allowed to update the billing email details. - The update customer endpoint (`PUT /platform/organizations/{slug}/customer`) is used to update the billing email details. ### Additional Context [Platform PR](https://github.com/supabase/platform/pull/37145), needs to be deployed first. ## Summary by CodeRabbit * **New Features** * Billing email settings now use customer profile information. * Added support for updating primary and additional billing email addresses. * Billing customer details now display address and billing name information. * **Bug Fixes** * Prevented unrelated billing profile fields from being overwritten during updates. * Billing forms now synchronize correctly when customer profile data changes. * Removed unnecessary organization name requirements from billing profile updates. --- .../BillingCustomerData.tsx | 5 +- .../BillingSettings/BillingEmail.test.tsx | 252 ++++++++++++++++++ .../BillingSettings/BillingEmail.tsx | 66 ++--- ...zation-customer-profile-update-mutation.ts | 19 +- 4 files changed, 307 insertions(+), 35 deletions(-) create mode 100644 apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.test.tsx 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 } : {}), } } )