mirror of
https://github.com/supabase/supabase.git
synced 2026-10-19 08:09:11 +03:00
feat: payment method selection w/ org-billing-migration
This commit is contained in:
1 parent
4505109d41
commit
aaaaeecddd
3 files changed
+184
-169
No files matched your search
+11
-3
@@ -14,11 +14,13 @@ import { AddNewPaymentMethodModal } from 'components/interfaces/BillingV2'
|
||||
export interface PaymentMethodSelectionProps {
|
||||
selectedPaymentMethod?: string
|
||||
onSelectPaymentMethod: (id: string) => void
|
||||
layout?: 'vertical' | 'horizontal'
|
||||
}
|
||||
|
||||
const PaymentMethodSelection = ({
|
||||
selectedPaymentMethod,
|
||||
onSelectPaymentMethod,
|
||||
layout = 'vertical',
|
||||
}: PaymentMethodSelectionProps) => {
|
||||
const { ui } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
@@ -70,8 +72,7 @@ const PaymentMethodSelection = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Select payment method</p>
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center px-4 py-2 space-x-4 border rounded-md border-scale-700 bg-scale-400">
|
||||
<IconLoader className="animate-spin" size={14} />
|
||||
@@ -91,6 +92,7 @@ const PaymentMethodSelection = ({
|
||||
disabled={!canUpdatePaymentMethods}
|
||||
icon={<IconCreditCard />}
|
||||
onClick={() => setShowAddNewPaymentMethodModal(true)}
|
||||
htmlType='button'
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
@@ -116,7 +118,13 @@ const PaymentMethodSelection = ({
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
) : (
|
||||
<Listbox value={selectedPaymentMethod} onChange={onSelectPaymentMethod}>
|
||||
<Listbox
|
||||
layout={layout}
|
||||
label="Payment method"
|
||||
value={selectedPaymentMethod}
|
||||
onChange={onSelectPaymentMethod}
|
||||
className="flex items-center"
|
||||
>
|
||||
{paymentMethods.map((method: any) => {
|
||||
const label = `•••• •••• •••• ${method.card.last4}`
|
||||
return (
|
||||
|
||||
+169
-165
@@ -10,6 +10,7 @@ import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
|
||||
import { PRICING_TIER_LABELS_ORG } from 'lib/constants'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import PaymentMethodSelection from '../BillingSettingsV2/Subscription/PaymentMethodSelection'
|
||||
|
||||
const MigrateOrganizationBillingButton = observer(() => {
|
||||
const { ui } = useStore()
|
||||
@@ -21,6 +22,8 @@ const MigrateOrganizationBillingButton = observer(() => {
|
||||
const [tier, setTier] = useState('')
|
||||
const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
|
||||
const [isSpendCapEnabled, setIsSpendCapEnabled] = useState(true)
|
||||
const [paymentMethodId, setPaymentMethodId] = useState('')
|
||||
const [isSubmitting, setSubmitting] = useState(false)
|
||||
|
||||
const dbTier = useMemo(() => {
|
||||
if (tier === '') return ''
|
||||
@@ -67,15 +70,9 @@ const MigrateOrganizationBillingButton = observer(() => {
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
const onValidate = (values: any) => {
|
||||
const errors: any = {}
|
||||
if (!values.tier) {
|
||||
errors.tier = 'Please select a plan.'
|
||||
}
|
||||
return errors
|
||||
}
|
||||
const onConfirmMigrate = async () => {
|
||||
if (!tier) return
|
||||
|
||||
const onConfirmMigrate = async (values: any, { setSubmitting }: any) => {
|
||||
if (!canMigrateOrganization) {
|
||||
return ui.setNotification({
|
||||
category: 'error',
|
||||
@@ -86,7 +83,7 @@ const MigrateOrganizationBillingButton = observer(() => {
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
await migrateBilling({ organizationSlug: organization?.slug, tier: dbTier })
|
||||
await migrateBilling({ organizationSlug: organization?.slug, tier: dbTier, paymentMethodId })
|
||||
ui.setNotification({
|
||||
message: 'Successfully migrated to organization-level billing',
|
||||
category: 'success',
|
||||
@@ -119,167 +116,174 @@ const MigrateOrganizationBillingButton = observer(() => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
validateOnBlur
|
||||
initialValues={{ tier: '', isSpendCapEnabled: true }}
|
||||
onSubmit={onConfirmMigrate}
|
||||
validate={onValidate}
|
||||
>
|
||||
{({ isSubmitting }: { isSubmitting: boolean }) => (
|
||||
<div className="space-y-4 py-3">
|
||||
<Modal.Content>
|
||||
<div className="space-y-2">
|
||||
<Alert title="About the migration" withIcon variant="info">
|
||||
<div className="text-sm space-y-2">
|
||||
<p>
|
||||
Migrating to new organization-level billing combines subscriptions for all
|
||||
projects in the organization into a single subscription. This cannot be
|
||||
reversed.{' '}
|
||||
</p>
|
||||
<div className="space-y-4 py-3">
|
||||
<Modal.Content>
|
||||
<div className="space-y-2">
|
||||
<Alert title="About the migration" withIcon variant="info">
|
||||
<div className="text-sm space-y-2">
|
||||
<p>
|
||||
Migrating to new organization-level billing combines subscriptions for all
|
||||
projects in the organization into a single subscription. This cannot be
|
||||
reversed.{' '}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
For a detailed breakdown of changes, see{' '}
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
Billing Migration Docs
|
||||
</a>
|
||||
</Link>
|
||||
. To transfer projects to a different organization, visit{' '}
|
||||
<Link href="/projects/_/settings/general">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
General settings
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
<p>
|
||||
For a detailed breakdown of changes, see{' '}
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
Billing Migration Docs
|
||||
</a>
|
||||
</Link>
|
||||
. To transfer projects to a different organization, visit{' '}
|
||||
<Link href="/projects/_/settings/general">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
General settings
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content>
|
||||
<Listbox
|
||||
id="tier"
|
||||
label="Organization Pricing Plan"
|
||||
layout="horizontal"
|
||||
value={tier}
|
||||
onChange={setTier}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Listbox.Option label="Select plan" value="" disabled className="hidden">
|
||||
Select Plan
|
||||
</Listbox.Option>
|
||||
{Object.entries(PRICING_TIER_LABELS_ORG).map(([k, v]) => {
|
||||
return (
|
||||
<Listbox.Option key={k} label={v} value={k}>
|
||||
{v}
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
<p className="text-sm text-scale-1000 mt-4">
|
||||
The pricing plan, along with included usage limits will apply to your entire
|
||||
organization. See{' '}
|
||||
<a
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://supabase.com/pricing"
|
||||
>
|
||||
Pricing
|
||||
</a>{' '}
|
||||
for more details. Please contact support if you are an Enterprise customer.
|
||||
</p>
|
||||
|
||||
{tier !== '' && tier !== 'FREE' && (
|
||||
<div className="my-2 space-y-1 pb-4">
|
||||
<p className="text-sm text-scale-1000">
|
||||
Paid plans come with one compute instance included. Additional projects will at
|
||||
least cost the compute instance hours used (min $7/month). See{' '}
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
Compute Instance Usage Billing
|
||||
</a>
|
||||
</Link>{' '}
|
||||
for more details.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
|
||||
<Modal.Separator />
|
||||
|
||||
{tier === 'PRO' && (
|
||||
<>
|
||||
<Modal.Content>
|
||||
<div className="mt-4 grid grid-cols-8 gap-x-8 gap-y-2">
|
||||
<div className="space-y-2 col-span-4">
|
||||
<p className="text-sm flex items-center gap-4">
|
||||
Enable spend cap{' '}
|
||||
<IconHelpCircle
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className="transition opacity-50 cursor-pointer hover:opacity-100"
|
||||
onClick={() => setShowSpendCapHelperModal(true)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-8">
|
||||
<Toggle
|
||||
id="isSpendCapEnabled"
|
||||
layout="vertical"
|
||||
checked={isSpendCapEnabled}
|
||||
onChange={() => setIsSpendCapEnabled(!isSpendCapEnabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12">
|
||||
<p className="text-sm text-scale-1000">
|
||||
When enabled, usage is limited to the plan's quota, with restrictions when
|
||||
limits are exceeded. To scale beyond Pro limits without restrictions, disable
|
||||
the spend cap and pay for over-usage beyond the quota.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SpendCapModal
|
||||
visible={showSpendCapHelperModal}
|
||||
onHide={() => setShowSpendCapHelperModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content>
|
||||
<Listbox
|
||||
id="tier"
|
||||
label="Organization Pricing Plan"
|
||||
layout="horizontal"
|
||||
value={tier}
|
||||
onChange={setTier}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Listbox.Option label="Select plan" value="" disabled className="hidden">
|
||||
Select Plan
|
||||
</Listbox.Option>
|
||||
{Object.entries(PRICING_TIER_LABELS_ORG).map(([k, v]) => {
|
||||
return (
|
||||
<Listbox.Option key={k} label={v} value={k}>
|
||||
{v}
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
<p className="text-sm text-scale-1000 mt-4">
|
||||
The pricing plan, along with included usage limits will apply to your entire
|
||||
organization. See{' '}
|
||||
<a
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://supabase.com/pricing"
|
||||
>
|
||||
Pricing
|
||||
</a>{' '}
|
||||
for more details. Please contact support if you are an Enterprise customer.
|
||||
</p>
|
||||
|
||||
{tier !== '' && tier !== 'FREE' && (
|
||||
<div className="my-2 space-y-1 pb-4">
|
||||
<p className="text-sm text-scale-1000">
|
||||
Paid plans come with one compute instance included. Additional projects will
|
||||
at least cost the compute instance hours used (min $7/month). See{' '}
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
Compute Instance Usage Billing
|
||||
</a>
|
||||
</Link>{' '}
|
||||
for more details.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Modal.Separator />
|
||||
{tier === 'PRO' && (
|
||||
<div className="mt-4 grid grid-cols-8 gap-8 ">
|
||||
<div className="space-y-2 col-span-5">
|
||||
<h4 className="flex items-center gap-4">
|
||||
Spend Cap{' '}
|
||||
<IconHelpCircle
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className="transition opacity-50 cursor-pointer hover:opacity-100"
|
||||
onClick={() => setShowSpendCapHelperModal(true)}
|
||||
/>
|
||||
</h4>
|
||||
<p className="text-sm text-scale-1000">
|
||||
When enabled, usage is limited to the plan's quota, with restrictions when
|
||||
limits are exceeded.{' '}
|
||||
</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
To scale beyond Pro limits without restrictions, disable the spend cap and
|
||||
pay for over-usage beyond the quota.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<Toggle
|
||||
id="isSpendCapEnabled"
|
||||
layout="vertical"
|
||||
label={
|
||||
<div className="flex space-x-4">
|
||||
<span>Enable Spend Cap</span>
|
||||
</div>
|
||||
}
|
||||
checked={isSpendCapEnabled}
|
||||
onChange={() => setIsSpendCapEnabled(!isSpendCapEnabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpendCapModal
|
||||
visible={showSpendCapHelperModal}
|
||||
onHide={() => setShowSpendCapHelperModal(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Content>
|
||||
<Loading active={tier !== '' && migrationPreviewIsLoading}>
|
||||
{migrationPreviewError && (
|
||||
<Alert title="Organization cannot be migrated" variant="danger">
|
||||
<span>{migrationPreviewError.message}</span>
|
||||
</Alert>
|
||||
)}
|
||||
</Loading>
|
||||
|
||||
{migrationError && (
|
||||
<Alert title="Organization cannot be migrated" variant="danger">
|
||||
<span>{migrationError.message}</span>
|
||||
</Alert>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Content>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={migrationPreviewData === undefined || isSubmitting}
|
||||
>
|
||||
I understand, migrate this organization
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
{tier && tier !== 'FREE' && (
|
||||
<>
|
||||
<Modal.Content>
|
||||
<PaymentMethodSelection
|
||||
layout="horizontal"
|
||||
onSelectPaymentMethod={(pm) => setPaymentMethodId(pm)}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal.Content>
|
||||
<Loading active={tier !== '' && migrationPreviewIsLoading}>
|
||||
{migrationPreviewError && (
|
||||
<Alert title="Organization cannot be migrated" variant="danger">
|
||||
<span>{migrationPreviewError.message}</span>
|
||||
</Alert>
|
||||
)}
|
||||
</Loading>
|
||||
|
||||
{migrationError && (
|
||||
<Alert title="Organization cannot be migrated" variant="danger">
|
||||
<span>{migrationError.message}</span>
|
||||
</Alert>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Content>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={migrationPreviewData === undefined || isSubmitting || !tier}
|
||||
onClick={() => onConfirmMigrate()}
|
||||
>
|
||||
I understand, migrate this organization
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { organizationKeys } from './keys'
|
||||
export type OrganizationBillingMigrationVariables = {
|
||||
organizationSlug?: string
|
||||
tier?: string
|
||||
paymentMethodId?: string
|
||||
}
|
||||
|
||||
type OrganizationBillingMigrationError = {
|
||||
@@ -15,12 +16,14 @@ type OrganizationBillingMigrationError = {
|
||||
export async function migrateBilling({
|
||||
organizationSlug,
|
||||
tier,
|
||||
paymentMethodId,
|
||||
}: OrganizationBillingMigrationVariables) {
|
||||
if (!organizationSlug) throw new Error('organizationSlug is required')
|
||||
if (!tier) throw new Error('tier is required')
|
||||
|
||||
const payload: { tier: string } = {
|
||||
const payload: { tier: string; payment_method_id?: string } = {
|
||||
tier,
|
||||
payment_method_id: paymentMethodId,
|
||||
}
|
||||
|
||||
const response = await post(
|
||||
|
||||
Reference in new issue
Block a user