Merge pull request #14659 from supabase/poc/org-level-billing

This commit is contained in:
Kevin Grüneberg authored and GitHub committed 2023-05-29 22:14:01 +02:00
commit 2131a400de
10 files changed
+535 -9

No files matched your search

@@ -1,6 +1,6 @@
import Link from 'next/link'
import { FC } from 'react'
import { Button, IconPlus } from 'ui'
import { Badge, Button, IconPlus } from 'ui'
import { observer } from 'mobx-react-lite'
import { IS_PLATFORM } from 'lib/constants'
@@ -28,7 +28,7 @@ const ProjectList: FC<Props> = ({ rewriteHref }) => {
return (
<>
{organizations.list().map((org: Organization) => {
const { id, name, slug } = org
const { id, name, slug, subscription_id } = org
const sortedProjects = projects.list(
({ organization_id }: Project) => organization_id == id
)
@@ -40,7 +40,7 @@ const ProjectList: FC<Props> = ({ rewriteHref }) => {
return (
<div className="space-y-3" key={makeRandomString(5)}>
<div className="flex space-x-4 items-center">
<h4 className="text-lg">{name}</h4>
<h4 className="text-lg flex items-center">{name}{subscription_id ? <Badge className='ml-3'>V2</Badge> : <></>}</h4>
{!!overdueInvoices.length && (
<div>
@@ -0,0 +1,334 @@
import { useState } from 'react'
import { Button, IconEdit2, IconInfo, Input, Listbox } from 'ui'
import { useRouter } from 'next/router'
import { API_URL, BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants'
import { useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import Panel from 'components/ui/Panel'
import { useElements, useStripe, PaymentElement } from '@stripe/react-stripe-js'
import { PaymentMethod } from 'components/interfaces/Billing/Billing.types'
import { getURL } from 'lib/helpers'
import InformationBox from 'components/ui/InformationBox'
const ORG_KIND_TYPES = {
PERSONAL: 'Personal',
EDUCATIONAL: 'Educational',
STARTUP: 'Startup',
AGENCY: 'Agency',
COMPANY: 'Company',
UNDISCLOSED: 'N/A',
}
const ORG_KIND_DEFAULT = 'PERSONAL'
const ORG_SIZE_TYPES = {
'1': '1 - 10',
'10': '10 - 49',
'50': '50 - 99',
'100': '100 - 299',
'300': 'More than 300',
}
const ORG_SIZE_DEFAULT = '1'
interface NewOrgFormProps {
onPaymentMethodReset: () => void
}
/**
* No org selected yet, create a new one
*/
const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
const { ui, app } = useStore()
const router = useRouter()
const stripe = useStripe()
const elements = useElements()
const [orgName, setOrgName] = useState('')
const [orgKind, setOrgKind] = useState(ORG_KIND_DEFAULT)
const [orgSize, setOrgSize] = useState(ORG_SIZE_DEFAULT)
const [newOrgLoading, setNewOrgLoading] = useState(false)
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>()
const [dbPricingTierKey, setDbPricingTierKey] = useState('PRO')
function validateOrgName(name: any) {
const value = name ? name.trim() : ''
return value.length >= 1
}
function onOrgNameChange(e: any) {
setOrgName(e.target.value)
}
function onOrgKindChange(value: any) {
setOrgKind(value)
}
function onOrgSizeChange(value: any) {
setOrgSize(value)
}
function onDbPricingPlanChange(value: string) {
setDbPricingTierKey(value)
}
async function createOrg(paymentMethodId: string) {
const response = await post(
`${API_URL}/organizations`,
{
name: orgName,
kind: orgKind,
payment_method: paymentMethodId,
tier: 'tier_' + dbPricingTierKey.toLowerCase(),
...(orgKind == 'COMPANY' ? { size: orgSize } : {}),
},
// Call new V2 endpoint from API
{
headers: {
Version: '2',
},
}
)
if (response.error) {
ui.setNotification({
category: 'error',
message: `Failed to create organization: ${response.error?.message ?? response.error}`,
})
} else {
const org = response
app.onOrgAdded(org)
router.push(`/new/${org.slug}`)
}
setNewOrgLoading(false)
}
const handleSubmit = async (event: any) => {
event.preventDefault()
const isOrgNameValid = validateOrgName(orgName)
if (!isOrgNameValid) {
ui.setNotification({ category: 'error', message: 'Organization name is empty' })
return
}
if (!stripe || !elements) {
console.error('Stripe.js has not loaded')
return
}
setNewOrgLoading(true)
if (!paymentMethod) {
const { error, setupIntent } = await stripe.confirmSetup({
elements,
redirect: 'if_required',
confirmParams: {
return_url: `${getURL()}/new-with-subscription`,
expand: ['payment_method'],
},
})
if (error || !setupIntent.payment_method) {
ui.setNotification({
category: 'error',
message: error?.message ?? ' Failed to save card details',
})
setNewOrgLoading(false)
return
}
const paymentMethodFromSetup = setupIntent.payment_method as PaymentMethod
setPaymentMethod(paymentMethodFromSetup)
await createOrg(paymentMethodFromSetup.id)
} else {
await createOrg(paymentMethod.id)
}
}
const resetPaymentMethod = () => {
setPaymentMethod(undefined)
return onPaymentMethodReset()
}
return (
<>
<form onSubmit={handleSubmit}>
<Panel
hideHeaderStyling
title={
<div key="panel-title">
<h4>Create a new organization</h4>
</div>
}
footer={
<div key="panel-footer" className="flex w-full items-center justify-between">
<Button type="default" onClick={() => router.push('/projects')}>
Cancel
</Button>
<div className="flex items-center space-x-3">
<p className="text-xs text-scale-900">You can rename your organization later</p>
<Button
block
htmlType="submit"
type="primary"
loading={newOrgLoading}
disabled={newOrgLoading}
>
Create organization
</Button>
</div>
</div>
}
>
<Panel.Content>
<InformationBox
icon={<IconInfo size="large" strokeWidth={1.5} />}
defaultVisibility={true}
hideCollapse
title="Billed via organization"
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
This is heavy Work-In-Progress and not customer facing yet, use with caution!
This organization will use the new org level billing, instead of having
individual subscriptions per project. There are still a lot of open ends that
may be restrictive for you, follow #team-billing for updates.
</p>
</div>
}
/>
</Panel.Content>
<Panel.Content className="pt-0">
<p className="text-sm">This is your organization within Supabase.</p>
<p className="text-sm text-scale-1100">
For example, you can use the name of your company or department.
</p>
</Panel.Content>
<Panel.Content className="Form section-block--body has-inputs-centered">
<Input
autoFocus
label="Name"
type="text"
layout="horizontal"
placeholder="Organization name"
descriptionText="What's the name of your company or team?"
value={orgName}
onChange={onOrgNameChange}
/>
</Panel.Content>
<Panel.Content className="Form section-block--body has-inputs-centered">
<Listbox
label="Type of organization"
layout="horizontal"
value={orgKind}
onChange={onOrgKindChange}
descriptionText="What would best describe your organization?"
>
{Object.entries(ORG_KIND_TYPES).map(([k, v]) => {
return (
<Listbox.Option key={k} label={v} value={k}>
{v}
</Listbox.Option>
)
})}
</Listbox>
</Panel.Content>
{orgKind == 'COMPANY' ? (
<Panel.Content className="Form section-block--body has-inputs-centered">
<Listbox
label="Company size"
layout="horizontal"
value={orgSize}
onChange={onOrgSizeChange}
descriptionText="How many people are in your company?"
>
{Object.entries(ORG_SIZE_TYPES).map(([k, v]) => {
return (
<Listbox.Option key={k} label={v} value={k}>
{v}
</Listbox.Option>
)
})}
</Listbox>
</Panel.Content>
) : (
<></>
)}
<Panel.Content>
<Listbox
label="Pricing Plan"
layout="horizontal"
value={dbPricingTierKey}
// @ts-ignore
onChange={onDbPricingPlanChange}
// @ts-ignore
descriptionText={
<>
Select a plan that suits your needs.&nbsp;
<a
className="underline"
target="_blank"
rel="noreferrer"
href="https://supabase.com/pricing"
>
More details
</a>
</>
}
>
{Object.entries(PRICING_TIER_LABELS_ORG).map(([k, v]) => {
return (
<Listbox.Option key={k} label={v} value={k}>
{v}
</Listbox.Option>
)
})}
</Listbox>
</Panel.Content>
<Panel.Content>
{paymentMethod ? (
<div key={paymentMethod.id} className="flex items-center justify-between">
<div className="flex items-center space-x-8">
<img
alt="Card"
src={`${BASE_PATH}/img/payment-methods/${paymentMethod.card.brand
.replace(' ', '-')
.toLowerCase()}.png`}
width="32"
/>
<Input
readOnly
className="w-64"
size="small"
value={`•••• •••• •••• ${paymentMethod.card.last4}`}
/>
<p className="text-sm tabular-nums">
Expires: {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year}
</p>
</div>
<div>
<Button
type="outline"
icon={<IconEdit2 />}
onClick={() => resetPaymentMethod()}
disabled={newOrgLoading}
className="hover:border-gray-500"
/>
</div>
</div>
) : (
<PaymentElement />
)}
</Panel.Content>
</Panel>
</form>
</>
)
}
export default NewOrgForm
@@ -2,5 +2,6 @@ import GeneralSettings from './GeneralSettings/GeneralSettings'
import TeamSettings from './TeamSettings/TeamSettings'
import BillingSettings from './BillingSettings/BillingSettings'
import InvoicesSettings from './InvoicesSettings/InvoicesSettings'
import NewOrgForm from './NewOrg/NewOrgForm'
export { GeneralSettings, TeamSettings, BillingSettings, InvoicesSettings }
export { GeneralSettings, TeamSettings, BillingSettings, InvoicesSettings, NewOrgForm }
@@ -37,7 +37,7 @@ const AccountLayout: FC<Props> = ({ children, title, breadcrumbs }) => {
.map((organization) => ({
isActive:
router.pathname.startsWith('/org/') && ui.selectedOrganization?.slug === organization.slug,
label: organization.name,
label: organization.name + (organization.subscription_id ? ' (V2)' : ''),
href: `/org/${organization.slug}/general`,
key: organization.slug,
}))
@@ -3,7 +3,7 @@ import { toJS } from 'mobx'
import { observer } from 'mobx-react-lite'
import { Button, Dropdown, IconPlus } from 'ui'
import { useStore } from 'hooks'
import { useFlag, useStore } from 'hooks'
import { IS_PLATFORM } from 'lib/constants'
const OrgDropdown = () => {
@@ -13,6 +13,8 @@ const OrgDropdown = () => {
const sortedOrganizations: any[] = app.organizations.list()
const selectedOrganization: any = ui.selectedOrganization
const orgCreationV2 = useFlag('orgcreationv2')
return IS_PLATFORM ? (
<Dropdown
side="bottom"
@@ -54,6 +56,14 @@ const OrgDropdown = () => {
<Dropdown.Item icon={<IconPlus size="tiny" />} onClick={() => router.push(`/new`)}>
New organization
</Dropdown.Item>
{orgCreationV2 && (
<Dropdown.Item
icon={<IconPlus size="tiny" />}
onClick={() => router.push(`/new-with-subscription`)}
>
New organization V2
</Dropdown.Item>
)}
</>
}
>
@@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { toJS } from 'mobx'
import { Button, Dropdown, IconPlus } from 'ui'
import { observer } from 'mobx-react-lite'
import { useFlag } from 'hooks'
const OrganizationDropdown = ({ organizations }) => {
const router = useRouter()
@@ -11,6 +12,8 @@ const OrganizationDropdown = ({ organizations }) => {
a.name.localeCompare(b.name)
)
const orgCreationV2 = useFlag('orgcreationv2')
return (
<Dropdown
side="bottom"
@@ -33,6 +36,14 @@ const OrganizationDropdown = ({ organizations }) => {
<Dropdown.Item icon={<IconPlus size="tiny" />} onClick={() => router.push(`/new`)}>
New organization
</Dropdown.Item>
{orgCreationV2 && (
<Dropdown.Item
icon={<IconPlus size="tiny" />}
onClick={() => router.push(`/new-with-subscription`)}
>
New organization V2
</Dropdown.Item>
)}
</>
}
>
+5
View File
@@ -26,6 +26,11 @@ export const PRICING_TIER_LABELS = {
PRO: 'Pro',
}
export const PRICING_TIER_LABELS_ORG = {
PRO: 'Pro - $25/month',
TEAM: 'Team - $599/month',
}
export const PRICING_TIER_PRODUCT_IDS = {
FREE: 'tier_free',
PRO: 'tier_pro',
+135
View File
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { API_URL, STRIPE_PUBLIC_KEY } from 'lib/constants'
import { useFlag, useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import { WizardLayout } from 'components/layouts'
import { NextPageWithLayout } from 'types'
import { Elements } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { NewOrgForm } from 'components/interfaces/Organization'
import { useTheme } from 'common'
import { useRouter } from 'next/router'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
/**
* No org selected yet, create a new one
*/
const Wizard: NextPageWithLayout = () => {
const { ui } = useStore()
const router = useRouter()
const [intent, setIntent] = useState<any>()
const captchaLoaded = useIsHCaptchaLoaded()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
const orgCreationV2 = useFlag('orgcreationv2')
const { isDarkMode } = useTheme()
const captchaRefCallback = useCallback((node) => {
setCaptchaRef(node)
}, [])
useEffect(() => {
if (orgCreationV2 === false) {
router.push('/new')
}
}, [orgCreationV2])
const setupIntent = async (hcaptchaToken: string | undefined) => {
// Force a reload of Elements, necessary for Stripe
setIntent(undefined)
const intent = await post(`${API_URL}/stripe/setup-intent`, {
hcaptchaToken,
})
if (intent.error) {
return ui.setNotification({
category: 'error',
message: intent.error.message,
error: intent.error,
})
} else {
setIntent(intent)
}
}
const options = {
clientSecret: intent ? intent.client_secret : '',
appearance: { theme: isDarkMode ? 'night' : 'flat', labels: 'floating' },
} as any
const loadPaymentForm = async () => {
if (captchaRef && captchaLoaded) {
let token = captchaToken
try {
if (!token) {
const captchaResponse = await captchaRef.execute({ async: true })
token = captchaResponse?.response ?? null
}
} catch (error) {
return
}
await setupIntent(token ?? undefined)
resetCaptcha()
}
}
useEffect(() => {
loadPaymentForm()
}, [captchaRef, captchaLoaded])
const resetSetupIntent = () => {
return loadPaymentForm()
}
const onLocalCancel = () => {
setIntent(undefined)
}
const resetCaptcha = () => {
setCaptchaToken(null)
captchaRef?.resetCaptcha()
}
return (
<>
<HCaptcha
ref={captchaRefCallback}
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
size="invisible"
onVerify={(token) => {
setCaptchaToken(token)
}}
onClose={onLocalCancel}
onExpire={() => {
setCaptchaToken(null)
}}
/>
{intent && (
<Elements stripe={stripePromise} options={options}>
<NewOrgForm onPaymentMethodReset={() => resetSetupIntent()} />
</Elements>
)}
</>
)
}
Wizard.getLayout = (page) => (
<WizardLayout organization={null} project={null}>
{page}
</WizardLayout>
)
export default observer(Wizard)
+32 -3
View File
@@ -4,7 +4,17 @@ import { debounce, isUndefined, values } from 'lodash'
import { toJS } from 'mobx'
import { observer } from 'mobx-react-lite'
import generator from 'generate-password'
import { Button, Listbox, IconUsers, Input, Alert, IconHelpCircle, Toggle } from 'ui'
import {
Button,
Listbox,
IconUsers,
Input,
Alert,
IconHelpCircle,
Toggle,
IconAlertCircle,
IconInfo,
} from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { NextPageWithLayout } from 'types'
@@ -35,6 +45,7 @@ import {
EmptyPaymentMethodWarning,
} from 'components/interfaces/Organization/NewProject'
import SpendCapModal from 'components/interfaces/Billing/SpendCapModal'
import InformationBox from 'components/ui/InformationBox'
const Wizard: NextPageWithLayout = () => {
const router = useRouter()
@@ -63,7 +74,7 @@ const Wizard: NextPageWithLayout = () => {
const organizations = values(toJS(app.organizations.list()))
const currentOrg = organizations.find((o: any) => o.slug === slug)
const stripeCustomerId = currentOrg?.stripe_customer_id
const billedViaOrg = Boolean(currentOrg?.subscription_id)
const availableRegions = getAvailableRegions()
const isAdmin = checkPermissions(PermissionAction.CREATE, 'projects')
@@ -290,6 +301,24 @@ const Wizard: NextPageWithLayout = () => {
</Listbox>
)}
{billedViaOrg && (
<InformationBox
icon={<IconInfo size="large" strokeWidth={1.5} />}
defaultVisibility={true}
hideCollapse
title="Billed via organization"
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
This is heavy Work-In-Progress and not customer facing yet, use with
caution! This organization uses the new org level billing, instead of having
individual subscriptions per project.
</p>
</div>
}
/>
)}
{!isAdmin && <NotOrganizationOwnerWarning />}
</Panel.Content>
@@ -397,7 +426,7 @@ const Wizard: NextPageWithLayout = () => {
</>
)}
{isAdmin && (
{isAdmin && !billedViaOrg && (
<Panel.Content>
<Listbox
label="Pricing Plan"
+1
View File
@@ -9,6 +9,7 @@ export interface Organization {
is_owner?: boolean
stripe_customer_id?: string
opt_in_tags: string[]
subscription_id?: string
}
export interface ProjectBase {