Files
supabase/apps/studio/components/interfaces/App/UpdateBillingAddressModal.tsx
Kanishk DudejaandIgnacio Dobronich 5d942e2a46 refactor(billing): allow GB VAT tax IDs for Isle of Man customers (#43640)
### Summary

This PR adds support for Isle of Man customers to use GB VAT tax IDs.
Isle of Man uses the UK VAT system, so Orb requires country: 'GB' for
validation - but the billing address country is IM.

- Adds an IM VAT entry to `TAX_IDS` with a new `taxCountryIso2` override
field, so the UI shows "IM VAT" while sending country: 'GB' to Orb
- Introduces `getEffectiveTaxCountry()` to resolve the correct country
code for Orb
- Introduces `resolveStoredTaxId()` to map stored Orb data back to the
correct UI entry, using billing address country to disambiguate IM vs GB
- Updates all three billing forms to use these utilities: Billing
Settings page, Add Payment Method modal and Update Billing Address modal

### Testing

#### Automated tests

I've added unit tests in this PR

#### Manual tests

You can use the following country/city/postal code combinations to test:

- Isle of Man, Douglas, IM1 2LE
- United Kingdom, Crumlin, BT29 4AA

**Billing Settings page**

- Select Isle of Man as billing country → tax ID dropdown shows "Isle of
Man - IM VAT"
- Select United Kingdom as billing country → tax ID dropdown shows
"United Kingdom - UK VAT" / "United Kingdom - GB VAT" (not "IM VAT")
- Select IM VAT, enter a valid GB VAT number, save → check network tab,
Orb receives country: 'GB'
- Reload page for an IM customer with existing tax ID → "IM VAT" is
pre-selected (not "UK VAT")
- Reload page for a GB customer with existing tax ID → "UK VAT" is
pre-selected (no regression)
- Change country from IM to GB → available tax IDs update, IM VAT no
longer shown

**Add Payment Method modal**

- Open modal for an IM customer with existing tax ID → "IM VAT" is
pre-selected
- Check "I'm purchasing as a business", select Isle of Man in Stripe
address → tax ID dropdown shows "IM VAT"
- Check "I'm purchasing as a business", select United Kingdom in Stripe
address → tax ID dropdown shows "UK VAT" / "GB VAT"
- Submit with IM VAT → check network tab, tax ID is saved with country:
'GB'
- Open modal for a GB customer → no regression, "UK VAT" shown as before

**Update Billing Address modal** 

Appears for paid orgs missing a billing address

- Open modal for an IM customer with existing tax ID → "IM VAT" is
pre-selected
- Open modal for a GB customer with existing tax ID → "UK VAT" is
pre-selected
- Fill in IM address + IM VAT and save → saves correctly, modal closes

---------

Co-authored-by: Ignacio Dobronich <ignacio@dobronich.com>
2026-03-20 21:48:56 +05:30

173 lines
5.9 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useFlag } from 'common'
import {
BillingCustomerDataForm,
type BillingCustomerDataFormValues,
} from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm'
import { resolveStoredTaxId } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils'
import { useBillingCustomerDataForm } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
Form_Shadcn_ as Form,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
export function UpdateBillingAddressModal() {
const queryClient = useQueryClient()
const [dismissed, setDismissed] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const showMissingAddressModal = useFlag('enableBillingAddressModal')
const { data: org } = useSelectedOrganizationQuery()
const slug = org?.slug
const { can: canBillingWrite, isSuccess: permissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.customer'
)
const shouldShow = Boolean(
IS_PLATFORM &&
showMissingAddressModal &&
org &&
org.plan.id !== 'free' &&
org.organization_missing_address &&
!org.billing_partner &&
permissionsLoaded &&
canBillingWrite
)
const {
data: customerProfile,
isSuccess: profileLoaded,
isError: profileError,
} = useOrganizationCustomerProfileQuery({ slug }, { enabled: shouldShow && !dismissed && !!slug })
const {
data: taxId,
isSuccess: taxIdLoaded,
isError: taxIdError,
} = useOrganizationTaxIdQuery({ slug }, { enabled: shouldShow && !dismissed && !!slug })
const open = shouldShow && !dismissed && !profileError && !taxIdError
const initialCustomerData = useMemo<Partial<BillingCustomerDataFormValues>>(
() => ({
city: customerProfile?.address?.city ?? undefined,
country: customerProfile?.address?.country,
line1: customerProfile?.address?.line1,
line2: customerProfile?.address?.line2 ?? undefined,
postal_code: customerProfile?.address?.postal_code ?? undefined,
state: customerProfile?.address?.state ?? undefined,
billing_name: customerProfile?.billing_name,
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
tax_id_name: taxId
? (resolveStoredTaxId(taxId.type, taxId.country, customerProfile?.address?.country)?.name ??
'')
: '',
}),
[customerProfile, taxId]
)
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
const { form, handleSubmit, isDirty } = useBillingCustomerDataForm({
initialCustomerData,
onCustomerDataChange: async (data) => {
if (!slug) return
setIsSubmitting(true)
try {
await updateCustomerProfile({
slug,
address: data.address,
billing_name: data.billing_name,
})
await updateTaxId({ slug, taxId: data.tax_id })
await invalidateOrganizationsQuery(queryClient)
toast.success('Successfully updated billing address')
setDismissed(true)
} catch (error: any) {
toast.error(`Failed to update billing address: ${error.message}`)
} finally {
setIsSubmitting(false)
}
},
})
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) setDismissed(true)
}}
>
<DialogContent
size="medium"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Billing address required</DialogTitle>
<DialogDescription>
Please provide a billing address for your organization. If you are a registered business
and have a Tax ID, please add your Tax ID too.
</DialogDescription>
</DialogHeader>
{!profileLoaded || !taxIdLoaded ? (
<DialogSection>
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
</DialogSection>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<DialogSection className="max-h-[60vh] overflow-y-auto">
<BillingCustomerDataForm form={form} />
</DialogSection>
<DialogFooter>
<Button
type="primary"
htmlType="submit"
loading={isSubmitting}
disabled={!isDirty || isSubmitting}
>
Save address
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
)
}