mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
### 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>
173 lines
5.9 KiB
TypeScript
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>
|
|
)
|
|
}
|