feat: payment method selection w/ org-billing-migration

This commit is contained in:
Kevin Grüneberg committed 2023-07-17 11:56:18 +02:00
1 parent 4505109d41
commit aaaaeecddd
3 files changed
+184 -169

No files matched your search

@@ -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 (
@@ -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(