diff --git a/apps/studio/components/interfaces/App/IndirectTaxDeclarationModal.tsx b/apps/studio/components/interfaces/App/IndirectTaxDeclarationModal.tsx new file mode 100644 index 00000000000..2f00d14e96f --- /dev/null +++ b/apps/studio/components/interfaces/App/IndirectTaxDeclarationModal.tsx @@ -0,0 +1,169 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + RadioGroupStacked, + RadioGroupStackedItem, +} from 'ui' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { IS_PLATFORM } from '@/lib/constants' + +type IndirectTaxDeclaration = 'yes' | 'no' +type DeclarationModal = 'declaration-form' | 'submission-confirmation' | null + +export const IndirectTaxDeclarationModal = () => { + const { data: organization } = useSelectedOrganizationQuery({ enabled: IS_PLATFORM }) + + const [response, setResponse] = useState('') + const [failedSubmissionSlug, setFailedSubmissionSlug] = useState() + + const [shouldShowDeclarationConfirmation, setShouldShowDeclarationConfirmation] = useQueryState( + 'submit_indirect_tax_declaration', + parseAsBoolean.withDefault(false) + ) + + useEffect(() => { + setResponse('') + }, [organization?.slug]) + + const { can: canUpdateBillingInfo, isSuccess: permissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.customer' + ) + + const { mutate: updateCustomerProfile, isPending } = useOrganizationCustomerProfileUpdateMutation( + { + onSuccess: () => { + if (!shouldShowDeclarationConfirmation) { + toast.success('GST declaration submitted') + } + }, + onError: (_error, variables) => { + setFailedSubmissionSlug(variables.slug) + toast.error("We couldn't submit your GST declaration. Reload the page and try again.", { + duration: Infinity, + }) + }, + } + ) + + const canViewDeclaration = + IS_PLATFORM && organization !== undefined && permissionsLoaded && canUpdateBillingInfo + + let declarationModal: DeclarationModal = null + + if (canViewDeclaration) { + if (organization.requires_indirect_tax_declaration) { + if (organization.slug !== failedSubmissionSlug) { + declarationModal = 'declaration-form' + } + } else if (shouldShowDeclarationConfirmation) { + declarationModal = 'submission-confirmation' + } + } + + const onSubmit = () => { + if (organization?.slug === undefined || response === '') return + + updateCustomerProfile({ + slug: organization.slug, + indirect_tax_registration_declaration: response, + }) + } + + const closeSubmissionConfirmation = () => { + setShouldShowDeclarationConfirmation(null) + } + + return ( + <> + + event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + > + + Confirm your Australian GST status + + Confirm the following for your organization {organization?.name} + + + + + + { + if (value === 'yes' || value === 'no') setResponse(value) + }} + > + + + + + + + + Submit declaration + + + + + + { + if (!open) closeSubmissionConfirmation() + }} + > + + + GST declaration submitted + + The GST declaration for {organization?.name} has been submitted. No further action is + required. + + + + + + + + + ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx index 05cd28f6e20..18937deb5b4 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx @@ -75,6 +75,7 @@ vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ restriction_status: null, restriction_data: null, organization_missing_address: false, + requires_indirect_tax_declaration: false, }, }), })) diff --git a/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts b/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts index d5344c308b3..af9e63db9d4 100644 --- a/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts +++ b/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { organizationKeys } from './keys' +import type { OrganizationsData } from './organizations-query' import type { CustomerAddress, CustomerTaxId } from './types' import { handleError, put } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -14,6 +15,7 @@ export type OrganizationCustomerProfileUpdateVariables = { tax_id?: CustomerTaxId | null email?: string additional_emails?: string[] + indirect_tax_registration_declaration?: 'yes' | 'no' /** When true, validates the request without persisting changes */ dry_run?: boolean } @@ -25,6 +27,7 @@ export async function updateOrganizationCustomerProfile({ tax_id, email, additional_emails, + indirect_tax_registration_declaration, dry_run, }: OrganizationCustomerProfileUpdateVariables) { if (!slug) return console.error('Slug is required') @@ -45,6 +48,7 @@ export async function updateOrganizationCustomerProfile({ : {}), email, additional_emails, + indirect_tax_registration_declaration, ...(dry_run ? { dry_run } : {}), }, }) @@ -77,7 +81,16 @@ export const useOrganizationCustomerProfileUpdateMutation = ({ >({ mutationFn: (vars) => updateOrganizationCustomerProfile(vars), async onSuccess(data, variables, context) { - const { address, slug, billing_name, tax_id, email, additional_emails, dry_run } = variables + const { + address, + slug, + billing_name, + tax_id, + email, + additional_emails, + indirect_tax_registration_declaration, + dry_run, + } = variables if (dry_run) { await onSuccess?.(data, variables, context) @@ -106,6 +119,16 @@ export const useOrganizationCustomerProfileUpdateMutation = ({ queryClient.setQueryData(organizationKeys.taxId(slug), tax_id) } + if (indirect_tax_registration_declaration !== undefined) { + queryClient.setQueryData(organizationKeys.list(), (previous) => + previous?.map((organization) => + organization.slug === slug + ? { ...organization, requires_indirect_tax_declaration: false } + : organization + ) + ) + } + // Refetch after a delay to pick up server-canonical values (e.g. normalized tax IDs). // The GET endpoint can be stale for 1-2 seconds after an update. setTimeout(() => { diff --git a/apps/studio/data/organizations/organization-mappers.test.ts b/apps/studio/data/organizations/organization-mappers.test.ts index 6b334622c68..41aaf38be15 100644 --- a/apps/studio/data/organizations/organization-mappers.test.ts +++ b/apps/studio/data/organizations/organization-mappers.test.ts @@ -20,8 +20,8 @@ const baseOrganizationResponse = { organization_missing_address: false, organization_missing_tax_id: false, organization_requires_mfa: false, - plan: { id: 'pro' as const, name: 'Pro' }, requires_indirect_tax_declaration: false, + plan: { id: 'pro' as const, name: 'Pro' }, restriction_data: null, restriction_status: null, slug: 'toolshed', diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index f7b7474a307..618085cdba4 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -42,6 +42,7 @@ import { StudioCommandMenu } from '@/components/interfaces/App/CommandMenu' import { StudioCommandProvider as CommandProvider } from '@/components/interfaces/App/CommandMenu/StudioCommandProvider' import { FeaturePreviewContextProvider } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { FeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewModal' +import { IndirectTaxDeclarationModal } from '@/components/interfaces/App/IndirectTaxDeclarationModal' import { MonacoThemeProvider } from '@/components/interfaces/App/MonacoThemeProvider' import { RouteValidationWrapper } from '@/components/interfaces/App/RouteValidationWrapper' import { MainScrollContainerProvider } from '@/components/layouts/MainScrollContainerContext' @@ -218,6 +219,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { + diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index 9c2e251ef9e..3609593df9d 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -64,6 +64,7 @@ import { StudioCommandMenu } from '@/components/interfaces/App/CommandMenu' import { StudioCommandProvider as CommandProvider } from '@/components/interfaces/App/CommandMenu/StudioCommandProvider' import { FeaturePreviewContextProvider } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { FeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewModal' +import { IndirectTaxDeclarationModal } from '@/components/interfaces/App/IndirectTaxDeclarationModal' import { MonacoThemeProvider } from '@/components/interfaces/App/MonacoThemeProvider' import { RouteValidationWrapper } from '@/components/interfaces/App/RouteValidationWrapper' import { ShellFallback } from '@/components/interfaces/App/ShellFallback' @@ -394,6 +395,7 @@ function RootComponent() { + diff --git a/apps/studio/tests/components/interfaces/App/IndirectTaxDeclarationModal.test.tsx b/apps/studio/tests/components/interfaces/App/IndirectTaxDeclarationModal.test.tsx new file mode 100644 index 00000000000..1c1b6d6f59c --- /dev/null +++ b/apps/studio/tests/components/interfaces/App/IndirectTaxDeclarationModal.test.tsx @@ -0,0 +1,201 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { toast } from 'sonner' +import { describe, expect, test, vi } from 'vitest' + +import { IndirectTaxDeclarationModal } from '@/components/interfaces/App/IndirectTaxDeclarationModal' +import { createMockOrganizationResponse } from '@/tests/helpers' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' +import { createMockProfileContext } from '@/tests/lib/profile-helpers' + +const ORG_SLUG = 'test-org' +const PROFILE_CONTEXT = createMockProfileContext() + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useParams: () => ({ slug: ORG_SLUG }), + useIsLoggedIn: () => true, + } +}) + +vi.mock('@/lib/constants', async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, IS_PLATFORM: true } +}) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + +const billingWritePermission = { + actions: [PermissionAction.BILLING_WRITE], + condition: null, + organization_id: 1, + organization_slug: ORG_SLUG, + project_ids: [], + project_refs: [], + resources: ['stripe.customer'], + restrictive: false, +} + +function setupMocks({ + canUpdateBilling = true, + requiresDeclaration = true, +}: { + canUpdateBilling?: boolean + requiresDeclaration?: boolean +} = {}) { + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: [ + createMockOrganizationResponse({ + slug: ORG_SLUG, + name: 'Test Org', + requires_indirect_tax_declaration: requiresDeclaration, + }), + ], + }) + + addAPIMock({ + method: 'get', + path: '/platform/profile/permissions', + response: canUpdateBilling ? [billingWritePermission] : [], + }) +} + +describe('IndirectTaxDeclarationModal', () => { + test('is non-dismissible and requires an explicit response', async () => { + setupMocks() + customRender(, { profileContext: PROFILE_CONTEXT }) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toHaveTextContent('Confirm your Australian GST status') + expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument() + + await userEvent.keyboard('{Escape}') + expect(screen.getByRole('dialog')).toBeInTheDocument() + + const submitButton = screen.getByRole('button', { name: 'Submit declaration' }) + expect(submitButton).toBeDisabled() + + await userEvent.hover(submitButton) + expect(await screen.findByRole('tooltip')).toHaveTextContent('Select Yes or No to continue') + }) + + test('submits the declaration', async () => { + setupMocks() + const requestBodies: unknown[] = [] + + addAPIMock({ + method: 'put', + path: '/platform/organizations/:slug/customer', + response: async ({ request }) => { + requestBodies.push(await request.json()) + return new HttpResponse(null, { status: 204 }) + }, + }) + + customRender(, { profileContext: PROFILE_CONTEXT }) + + await userEvent.click(await screen.findByRole('radio', { name: /Yes, I confirm/i })) + await userEvent.click(screen.getByRole('button', { name: 'Submit declaration' })) + + await waitFor(() => { + expect(requestBodies).toEqual([{ indirect_tax_registration_declaration: 'yes' }]) + }) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + test('transitions to confirmation after submitting from the declaration link', async () => { + setupMocks() + + addAPIMock({ + method: 'put', + path: '/platform/organizations/:slug/customer', + response: () => new HttpResponse(null, { status: 204 }), + }) + + customRender(, { + profileContext: PROFILE_CONTEXT, + nuqs: { searchParams: { submit_indirect_tax_declaration: 'true' } }, + }) + + await userEvent.click(await screen.findByRole('radio', { name: /Yes, I confirm/i })) + await userEvent.click(screen.getByRole('button', { name: 'Submit declaration' })) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toHaveTextContent('GST declaration submitted') + expect(dialog).toHaveTextContent( + 'The GST declaration for Test Org has been submitted. No further action is required.' + ) + }) + + test('closes after a failed submission without showing the submitted confirmation', async () => { + setupMocks() + + addAPIMock({ + method: 'put', + path: '/platform/organizations/:slug/customer', + response: () => HttpResponse.json({ message: 'Orb update failed' }, { status: 500 }), + }) + + customRender(, { + profileContext: PROFILE_CONTEXT, + nuqs: { searchParams: { submit_indirect_tax_declaration: 'true' } }, + }) + + await userEvent.click(await screen.findByRole('radio', { name: /Yes, I confirm/i })) + await userEvent.click(screen.getByRole('button', { name: 'Submit declaration' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.queryByText('GST declaration submitted')).not.toBeInTheDocument() + expect(toast.error).toHaveBeenCalledWith( + "We couldn't submit your GST declaration. Reload the page and try again.", + { duration: Infinity } + ) + }) + + test('does not show for members without billing write permission', async () => { + setupMocks({ canUpdateBilling: false }) + customRender(, { profileContext: PROFILE_CONTEXT }) + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + }) + + test('shows a dismissible confirmation when linked after a previous submission', async () => { + setupMocks({ requiresDeclaration: false }) + customRender(, { + profileContext: PROFILE_CONTEXT, + nuqs: { searchParams: { submit_indirect_tax_declaration: 'true' } }, + }) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toHaveTextContent('GST declaration submitted') + expect(dialog).toHaveTextContent( + 'The GST declaration for Test Org has been submitted. No further action is required.' + ) + + await userEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + test('does not show the submitted confirmation without the URL parameter', async () => { + setupMocks({ requiresDeclaration: false }) + customRender(, { profileContext: PROFILE_CONTEXT }) + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + }) +}) diff --git a/apps/studio/tests/lib/sql-editor-test-utils.tsx b/apps/studio/tests/lib/sql-editor-test-utils.tsx index 22d0180eebc..e332c1977d7 100644 --- a/apps/studio/tests/lib/sql-editor-test-utils.tsx +++ b/apps/studio/tests/lib/sql-editor-test-utils.tsx @@ -227,8 +227,8 @@ export function setupSqlEditorMocks({ organization_missing_address: false, organization_missing_tax_id: false, organization_requires_mfa: false, - plan: { id: 'free', name: 'Free' }, requires_indirect_tax_declaration: false, + plan: { id: 'free', name: 'Free' }, restriction_data: null, restriction_status: null, stripe_customer_id: null,