From 1a3f73e5c2515a0141fecd1d20c40ce5e17f9efd Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 29 Jun 2023 15:03:22 +0800 Subject: [PATCH 01/21] Scaffold new billing tab for org v2 --- .../BillingSettings/BillingSettingsV2.tsx | 19 ++ .../CostControl/CostControl.tsx | 151 +++++++++++++++ .../ProjectUpdateDisabledTooltip.tsx | 36 ++++ .../BillingSettings/Subscription/README.md | 7 + .../Subscription/Subscription.tsx | 174 ++++++++++++++++++ .../Organization/NewOrg/NewOrgForm.tsx | 1 - .../interfaces/Organization/index.ts | 11 +- .../subscriptions/org-subscription-query.ts | 3 +- studio/pages/org/[slug]/billing.tsx | 30 ++- studio/pages/org/[slug]/usage.tsx | 58 ++++++ 10 files changed, 478 insertions(+), 12 deletions(-) create mode 100644 studio/components/interfaces/Organization/BillingSettings/BillingSettingsV2.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettings/Subscription/README.md create mode 100644 studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx create mode 100644 studio/pages/org/[slug]/usage.tsx diff --git a/studio/components/interfaces/Organization/BillingSettings/BillingSettingsV2.tsx b/studio/components/interfaces/Organization/BillingSettings/BillingSettingsV2.tsx new file mode 100644 index 00000000000..8a3a736386f --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettings/BillingSettingsV2.tsx @@ -0,0 +1,19 @@ +import CostControl from './CostControl/CostControl' +import Subscription from './Subscription/Subscription' + +const BillingSettingsV2 = () => { + return ( + <> +
+ +
+
+
+ +
+
+ + ) +} + +export default BillingSettingsV2 diff --git a/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx new file mode 100644 index 00000000000..cc6641c7014 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -0,0 +1,151 @@ +import Link from 'next/link' + +import { useParams, useTheme } from 'common' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { BASE_PATH } from 'lib/constants' +import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' +import { Alert, Button, IconExternalLink } from 'ui' +// import SpendCapSidePanel from './SpendCapSidePanel' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useFlag } from 'hooks' +import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +export interface CostControlProps {} + +const CostControl = ({}: CostControlProps) => { + const { slug } = useParams() + const snap = useSubscriptionPageStateSnapshot() + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + const { isDarkMode } = useTheme() + + const { data: subscription, isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) + + const currentPlan = subscription?.plan + const isUsageBillingEnabled = subscription?.usage_billing_enabled ?? false + + const canChangeTier = + !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '') + + return ( + <> +
+
+
+
+
+

Cost Control

+

+ Control whether to use beyond your plans included quota +

+
+
+

More information

+ +
+
+
+
+ {isLoading ? ( +
+ + + +
+ ) : ( +
+ {['team', 'enterprise'].includes(currentPlan?.id || '') ? ( + + {currentPlan?.name || ''} plan requires you to have spend cap off at all times. Your + projects will never become unresponsive. Only when your{' '} + + + included usage + + {' '} + is exceeded will you be charged for any additional usage. + + ) : ( +

+ You can control whether your organization is charged for additional usage beyond the{' '} + + + included quota + + {' '} + of your subscription plan. If you need to go beyond the included quota, simply + switch off your spend cap to pay for additional usage. +

+ )} + +
+
+
+ Spend Cap +
+
+
+

+ Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'} +

+

+ {isUsageBillingEnabled ? ( + You will be charged for any usage above the included quota. + ) : ( + + You won't be charged any extra for usage. However, your projects could become + unresponsive or enter read only mode if you exceed the included quota. + + )} +

+ {isUsageBillingEnabled && ( +

+ Your projects will never become unresponsive. Only when your usage reaches the + quota limit will you be charged for any excess usage. +

+ )} + + + +
+
+
+ )} +
+ + {/* */} + + ) +} + +export default CostControl diff --git a/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx b/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx new file mode 100644 index 00000000000..873a8144f68 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx @@ -0,0 +1,36 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PropsWithChildren } from 'react' + +export interface ProjectUpdateDisabledTooltipProps { + projectUpdateDisabled: boolean +} + +const ProjectUpdateDisabledTooltip = ({ + projectUpdateDisabled, + children, +}: PropsWithChildren) => { + return ( + + {children} + {projectUpdateDisabled ? ( + + + +
+ + Subscription changes are currently disabled. Our engineers are working on a fix. + +
+
+
+ ) : null} +
+ ) +} + +export default ProjectUpdateDisabledTooltip diff --git a/studio/components/interfaces/Organization/BillingSettings/Subscription/README.md b/studio/components/interfaces/Organization/BillingSettings/Subscription/README.md new file mode 100644 index 00000000000..0ed42b4e4bc --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettings/Subscription/README.md @@ -0,0 +1,7 @@ +### Disclaimer + +[Joshen] Just FYI a lot of these are lifted from `components/interfaces/BillingV2/Subscription` since they are +more or less the same, in fact after migrating to Org billing completely, we will probably deprecate the BillingV2 folder +at the project scope. + +- Probably also need to update support form to allow selecting an organization, not just project diff --git a/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx new file mode 100644 index 00000000000..c44cb8fd99f --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -0,0 +1,174 @@ +import dayjs from 'dayjs' +import Link from 'next/link' + +import { useParams } from 'common' +import AlertError from 'components/ui/AlertError' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useFlag } from 'hooks' +import { Alert, Button, IconExternalLink } from 'ui' +import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' +import SparkBar from 'components/ui/SparkBar' + +const Subscription = () => { + const { slug } = useParams() + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + + const { + data: subscription, + error, + isLoading, + isError, + isSuccess, + } = useOrgSubscriptionQuery({ orgSlug: slug }) + + const currentPlan = subscription?.plan + const planName = currentPlan?.name ?? 'Unknown' + const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc() + const billingCycleEnd = dayjs.unix(subscription?.current_period_end ?? 0).utc() + const daysToCycleEnd = billingCycleEnd.diff(dayjs(), 'days') + const daysWithinCycle = billingCycleEnd.diff(billingCycleStart, 'days') + + const canChangeTier = + !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id ?? '') + + console.log({ subscription }) + + return ( +
+
+
+

Subscription plan

+
+

More information

+ +
+
+
+
+ {isLoading && ( +
+ + + +
+ )} + + {isError && } + + {isSuccess && ( +
+
+

This organization is currently on the plan:

+

{currentPlan?.name ?? 'Unknown'}

+
+ +
+ + + + {!canChangeTier && + (projectUpdateDisabled ? ( + + We have temporarily disabled project and subscription changes - our engineers + are working on a fix. + + ) : ( + + + + + + +
, + ]} + > + Please contact us if you'd like to change your plan. + + ))} +
+ + {!subscription?.usage_billing_enabled && ( + {}}> + Upgrade Plan + + ) : ( + // [Joshen TODO]: Implement + + ) + } + > +

+ When this organization exceeds its{' '} + + + included usage quotas + + + , its projects may become unresponsive.{' '} + {currentPlan?.id === 'free' ? ( +

+ If you wish to exceed the included usage, you should upgrade to a paid plan. +

+ ) : ( +

+ You currently have Spend Cap enabled - when you exceed your plan's limit, you + will experience restrictions. To scale seamlessly and pay for over-usage, you + can adjust your Cost Control settings. +

+ )} +

+
+ )} + + +
+ )} +
+
+ ) +} + +export default Subscription diff --git a/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index a722a301238..184eb20c7d2 100644 --- a/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -174,7 +174,6 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {

You can rename your organization later

+ + +
+

+ If you reach 95% of the disk space, your project will enter read-only mode. Your + disk storage expands automatically when the database reaches 90% of the disk size. + The disk is expanded to be 50% larger. Auto-scaling can only take place once every 6 + hours. You can also{' '} + + preprovision + {' '} + disk for loading larger amounts of data. +

+
+ ) : ( +
+ {(isApproachingLimit || isExceededLimit) && ( + +
+
+ When you reach your database size limit, your project can go into read-only + mode. Please upgrade your plan. +
+
+
+ )} +
+ ) + }, + }, + { + anchor: 'storageSize', + key: 'storage_size', + attribute: 'total_storage_size_bytes', + name: 'Storage Size', + chartPrefix: 'Max ', + unit: 'bytes', + description: + 'Sum of all objects in your storage buckets.\nBilling is based on the average daily size in GB throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Storage', + url: 'https://supabase.com/docs/guides/storage', + }, + ], + }, + { + anchor: 'funcCount', + key: 'func_count', + attribute: 'total_func_count', + name: 'Edge Function Count', + chartPrefix: 'Max ', + unit: 'absolute', + description: + 'Number of serverless functions in your project.\nBilling is based on the maximum amount of functions at any point in time throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + }, + ], + }, + { + key: 'activity', + name: 'Activity', + description: 'Usage statistics that reflect the activity of your project', + attributes: [ + { + anchor: 'mau', + key: 'monthly_active_users', + attribute: 'total_auth_billing_period_mau', + name: 'Monthly Active Users', + unit: 'absolute', + description: + 'Users who log in or refresh their token count towards MAU.\nBilling is based on the sum of distinct users requesting your API throughout the billing period. Resets every billing cycle.', + chartDescription: + 'The data is refreshed over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period.', + links: [ + { + name: 'Auth', + url: 'https://supabase.com/docs/guides/auth', + }, + ], + }, + { + anchor: 'mauSso', + key: 'monthly_active_sso_users', + attribute: 'total_auth_billing_period_sso_mau', + name: 'Monthly Active SSO Users', + unit: 'absolute', + description: + 'SSO users who log in or refresh their token count towards SSO MAU.\nBilling is based on the sum of distinct Single Sign-On users requesting your API throughout the billing period. Resets every billing cycle.', + chartDescription: + 'The data refreshes over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period.', + links: [ + { + name: 'SSO with SAML 2.0', + url: 'https://supabase.com/docs/guides/auth/sso/auth-sso-saml', + }, + ], + }, + { + anchor: 'storageImageTransformations', + key: 'storage_image_render_count', + attribute: 'total_storage_image_render_count', + name: 'Storage Image Transformations', + unit: 'absolute', + description: + 'We count all images that were transformed in the billing period, ignoring any transformations.\nUsage example: You transform one image with four different size transformations and another image with just a single transformation. It counts as two, as only two images were transformed.\nBilling is based on the count of (origin) images that used transformations throughout the billing period. Resets every billing cycle.', + chartDescription: + 'The data refreshes every 24 hours.\nThe data points are relative to the beginning of your billing period.', + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/storage/image-transformations', + }, + ], + }, + { + anchor: 'functionInvocations', + key: 'func_invocations', + attribute: 'total_func_invocations', + name: 'Edge Function Invocations', + unit: 'absolute', + description: + 'Every serverless function invocation independent of response status is counted.\nBilling is based on the sum of all invocations throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Edge Functions', + url: 'https://supabase.com/docs/guides/functions', + }, + ], + }, + { + anchor: 'realtimeMessageCount', + key: 'realtime_message_count', + attribute: 'total_realtime_message_count', + name: 'Realtime Message Count', + unit: 'absolute', + description: + "Count of messages going through Realtime.\nUsage example: If you do a database change and 5 clients listen to that change via Realtime, that's 5 messages. If you broadcast a message and 4 clients listen to that, that's 5 messages (1 message sent, 4 received).\nBilling is based on the total amount of messages throughout your billing period.", + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Realtime Quotas', + url: 'https://supabase.com/docs/guides/realtime/quotas', + }, + ], + }, + { + anchor: 'realtimePeakConnection', + key: 'realtime_peak_connection', + attribute: 'total_realtime_peak_connection', + name: 'Realtime Peak Connections', + chartPrefix: 'Max ', + unit: 'absolute', + description: + 'Total number of successful connections. Connections attempts are not counted towards usage.\nBilling is based on the maximum amount of concurrent peak connections throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Realtime Quotas', + url: 'https://supabase.com/docs/guides/realtime/quotas', + }, + ], + }, + ], + }, +] diff --git a/studio/components/interfaces/Organization/Usage/Usage.tsx b/studio/components/interfaces/Organization/Usage/Usage.tsx new file mode 100644 index 00000000000..650b0cd0582 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -0,0 +1,99 @@ +import dayjs from 'dayjs' +import { useMemo, useState } from 'react' + +import { useParams } from 'common' +import Bandwidth from './Bandwidth' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import DateRangePicker from 'components/to-be-cleaned/DateRangePicker' +import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants' +import SizeAndCounts from './SizeAndCounts' +import Activity from './Activity' + +const Usage = () => { + const { slug } = useParams() + const [dateRange, setDateRange] = useState() + + const selectedProjectRef = 'kojplftaeiavuneadgix' + + const { + data: subscription, + isLoading: isLoadingSubscription, + isError, + isSuccess, + } = useOrgSubscriptionQuery({ orgSlug: slug }) + + const currentBillingCycleSelected = useMemo(() => { + // Selected by default + if (!dateRange?.period_start || !dateRange?.period_end || !subscription) return true + + const { current_period_start, current_period_end } = subscription + + return ( + dayjs(dateRange.period_start.date).isSame(new Date(current_period_start * 1000)) && + dayjs(dateRange.period_end.date).isSame(new Date(current_period_end * 1000)) + ) + }, [dateRange, subscription]) + + const startDate = useMemo(() => { + // If end date is in future, set end date to now + if (!dateRange?.period_start?.date) { + return undefined + } else { + // LF seems to have an issue with the milliseconds, causes infinite loading sometimes + return new Date(dateRange?.period_start?.date).toISOString().slice(0, -5) + 'Z' + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dateRange, subscription]) + + const endDate = useMemo(() => { + // If end date is in future, set end date to end of current day + if (dateRange?.period_end?.date && dayjs(dateRange.period_end.date).isAfter(dayjs())) { + // LF seems to have an issue with the milliseconds, causes infinite loading sometimes + // In order to have full days from Prometheus metrics when using 1d interval, + // the time needs to be greater or equal than the time of the start date + return dayjs().endOf('day').toISOString().slice(0, -5) + 'Z' + } else if (dateRange?.period_end?.date) { + // LF seems to have an issue with the milliseconds, causes infinite loading sometimes + return new Date(dateRange.period_end.date).toISOString().slice(0, -5) + 'Z' + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dateRange, subscription]) + + return ( + <> + + + + + + ) +} + +export default Usage diff --git a/studio/components/interfaces/Organization/Usage/Usage.utils.ts b/studio/components/interfaces/Organization/Usage/Usage.utils.ts new file mode 100644 index 00000000000..6bc5eb09712 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/Usage.utils.ts @@ -0,0 +1,111 @@ +import { DataPoint } from 'data/analytics/constants' +import { ProjectUsageResponse } from 'data/usage/project-usage-query' +import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants' +import { CategoryAttribute, USAGE_STATUS } from './Usage.constants' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' + +// [Joshen] This is just for development to generate some test data for chart rendering +export const generateUsageData = (attribute: string, days: number): DataPoint[] => { + const tempArray = new Array(days).fill(0) + return tempArray.map((x, idx) => { + return { + loopId: (idx + 1).toString(), + period_start: `${idx + 1}`, + [attribute]: Math.floor(Math.random() * 100).toString(), + } + }) +} + +export const getUsageStatus = (attributes: CategoryAttribute[], usage?: ProjectUsageResponse) => { + if (!usage) return USAGE_STATUS.NORMAL + + const attributeStatuses = attributes.map((attribute) => { + const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse] + const usageRatio = + typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0 + if (usageRatio >= 1) return USAGE_STATUS.EXCEEDED + else if (usageRatio >= USAGE_APPROACHING_THRESHOLD) return USAGE_STATUS.APPROACHING + else return USAGE_STATUS.NORMAL + }) + + if (attributeStatuses.find((x) => x === USAGE_STATUS.EXCEEDED)) return USAGE_STATUS.EXCEEDED + else if (attributeStatuses.find((x) => x === USAGE_STATUS.APPROACHING)) + return USAGE_STATUS.APPROACHING + else return USAGE_STATUS.NORMAL +} + +export const getUpgradeUrl = (projectRef: string, subscription?: ProjectSubscriptionResponse) => { + if (!subscription) { + return `/project/${projectRef}/settings/billing/subscription` + } + + return subscription?.plan?.id === 'pro' && subscription?.usage_billing_enabled === false + ? `/project/${projectRef}/settings/billing/subscription#cost-control` + : `/project/${projectRef}/settings/billing/subscription?panel=subscriptionPlan` +} + +const compactNumberFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', +}) + +/** + * For the y-axis, we don't need to be as precise, to avoid showing 58.597MB. + */ +export const ChartYFormatterCompactNumber = (number: number | string, unit: string) => { + if (typeof number === 'string') return number + + if (unit === 'bytes') { + const formattedBytes = formatBytesCompact(number).replace(/\s/g, '') + + return formattedBytes === '0bytes' ? '0' : formattedBytes + } else { + return compactNumberFormatter.format(number) + } +} + +/** + * For the chart tooltip, we want to be more precise and show more decimals. + */ +export const ChartTooltipValueFormatter = (number: number | string, unit: string) => { + if (typeof number === 'string') return number + + if (unit === 'bytes') { + const formattedBytes = formatBytesPrecision(number).replace(/\s/g, '') + + return formattedBytes === '0bytes' ? '0' : formattedBytes + } else { + return compactNumberFormatter.format(number) + } +} + +const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + +export const formatBytesCompact = (bytes: number) => { + if (bytes === 0 || bytes === undefined) return '0 bytes' + + const k = 1024 + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + const unit = sizes[i] + + let dm = 2 + if (['bytes', 'KB', 'MB'].includes(unit)) { + dm = 0 + } else if (['GB'].includes(unit)) { + dm = 1 + } + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + unit +} + +export const formatBytesPrecision = (bytes: any) => { + if (bytes === 0 || bytes === undefined) return '0 bytes' + + const k = 1024 + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + const unit = sizes[i] + + return parseFloat((bytes / Math.pow(k, i)).toFixed(3)) + ' ' + unit +} diff --git a/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx b/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx new file mode 100644 index 00000000000..c93733f5eb6 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx @@ -0,0 +1,97 @@ +import { DataPoint } from 'data/analytics/constants' +import { + Bar, + CartesianGrid, + Cell, + ComposedChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import dayjs from 'dayjs' + +// [Joshen] This BarChart is specifically for usage, hence not a reusable component, and not +// replacing the existing BarChart in ui/Charts + +export interface UsageBarChartProps { + data: DataPoint[] + name: string // Used within the tooltip + attribute: string + unit: 'bytes' | 'absolute' | 'percentage' + yLimit?: number + yLeftMargin?: number + yFormatter?: (value: number | string) => string + tooltipFormatter?: (value: number | string) => string +} + +const UsageBarChart = ({ + data, + name, + attribute, + unit, + yLimit, + yLeftMargin = 10, + yFormatter, + tooltipFormatter +}: UsageBarChartProps) => { + const yMin = 0 // We can consider passing this as a prop if there's a use case in the future + + const yDomain = [yMin, yLimit ?? 0] + + + return ( +
+ + + + + + { + const { active, payload } = props + if (active && payload && payload.length) { + const dataPeriod = dayjs(payload[0].payload.period_start) + const value = + unit === 'percentage' + ? Number(payload[0].value).toFixed(2) + : Number(payload[0].value) + + return ( +
+

+ {attribute === 'disk_io_budget' ? `Remaining IO budget:` : `${name}:`} +

+ {dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? ( +

No data yet

+ ) : ( +

+ {tooltipFormatter !== undefined ? tooltipFormatter(value) : value} +

+ )} +

+ {dataPeriod.format('DD MMM YYYY')} +

+
+ ) + } else return null + }} + /> + + {data.map((entry) => { + return + })} + +
+
+
+ ) +} + +export default UsageBarChart diff --git a/studio/components/interfaces/Organization/Usage/UsageSection.tsx b/studio/components/interfaces/Organization/Usage/UsageSection.tsx new file mode 100644 index 00000000000..9b67c1fd239 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/UsageSection.tsx @@ -0,0 +1,283 @@ +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import SectionHeader from './SectionHeader' +import { CategoryMetaKey, USAGE_CATEGORIES } from './Usage.constants' +import { + ProjectUsageResponse, + UsageMetric, + useProjectUsageQuery, +} from 'data/usage/project-usage-query' +import { DataPoint } from 'data/analytics/constants' +import SectionContent from './SectionContent' +import { Button, IconAlertTriangle, IconBarChart2 } from 'ui' +import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants' +import Link from 'next/link' +import SparkBar from 'components/ui/SparkBar' +import clsx from 'clsx' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' +import { + ChartTooltipValueFormatter, + ChartYFormatterCompactNumber, + getUpgradeUrl, +} from './Usage.utils' +import { formatBytes } from 'lib/helpers' +import UsageBarChart from './UsageBarChart' +import Panel from 'components/ui/Panel' +import * as Tooltip from '@radix-ui/react-tooltip' + +interface UsageSectionProps { + projectRef: string + categoryKey: CategoryMetaKey + subscription: ProjectSubscriptionResponse | undefined + chartMeta: { + [key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean } + } + currentBillingCycleSelected: boolean +} + +const UsageSection = ({ + projectRef, + categoryKey, + chartMeta, + subscription, + currentBillingCycleSelected, +}: UsageSectionProps) => { + const { data: usage } = useProjectUsageQuery({ projectRef }) + const categoryMeta = USAGE_CATEGORIES.find((category) => category.key === categoryKey) + + if (!categoryMeta) return null + + const usageBasedBilling = subscription?.usage_billing_enabled + const exceededLimitStyle = !usageBasedBilling ? 'text-red-900' : 'text-amber-900' + + const upgradeUrl = getUpgradeUrl(projectRef, subscription) + + return ( + <> + + + {categoryMeta.attributes.map((attribute) => { + const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse] as UsageMetric + const usageRatio = + typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0 + const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.limit ?? 0) + + const chartData = chartMeta[attribute.key]?.data ?? [] + + const notAllValuesZero = chartData.some( + (dataPoint) => Number(dataPoint[attribute.attribute]) !== 0 + ) + + return ( +
+ + {usageMeta?.available_in_plan ? ( + <> +
+
+
+

{attribute.name} usage

+ {currentBillingCycleSelected && + usageBasedBilling === false && + usageRatio >= USAGE_APPROACHING_THRESHOLD && ( + + + {!usageBasedBilling && usageRatio >= 1 ? ( +
+ +

+ Exceeded limit +

+
+ ) : ( + !usageBasedBilling && + usageRatio >= USAGE_APPROACHING_THRESHOLD && ( +
+ +

Approaching limit

+
+ ) + )} +
+ + + +
+

+ Exceeding your plans included usage will lead to restrictions + to your project. +

+

+ Upgrade to a usage-based plan or disable the spend cap to + avoid restrictions. +

+
+
+
+
+ )} +
+ + {currentBillingCycleSelected && + !usageBasedBilling && + usageRatio >= USAGE_APPROACHING_THRESHOLD && ( + + + + + + )} +
+ {currentBillingCycleSelected && usageMeta?.limit > 0 && ( + = 1 + ? usageBasedBilling + ? 'bg-scale-1100' + : 'bg-red-900' + : usageBasedBilling === false && + usageRatio >= USAGE_APPROACHING_THRESHOLD + ? 'bg-amber-900' + : 'bg-scale-1100' + )} + bgClass="bg-gray-300 dark:bg-gray-600" + value={usageMeta?.usage ?? 0} + max={usageMeta?.limit || 1} + /> + )} +
+
+

+ Included in {subscription?.plan?.name.toLowerCase()} plan +

+ {usageMeta?.limit === -1 ? ( +

None

+ ) : usageMeta?.limit === 0 ? ( +

Unlimited

+ ) : ( +

+ {attribute.unit === 'bytes' + ? formatBytes(usageMeta?.limit ?? 0) + : (usageMeta?.limit ?? 0).toLocaleString()} +

+ )} +
+ {currentBillingCycleSelected && ( +
+

+ {attribute.chartPrefix || 'Used '}in period +

+

+ {attribute.unit === 'bytes' + ? formatBytes(usageMeta?.usage ?? 0) + : (usageMeta?.usage ?? 0).toLocaleString()} +

+
+ )} + {currentBillingCycleSelected && usageMeta?.limit > 0 && ( +
+

Overage in period

+

+ {(usageMeta?.limit ?? 0) === -1 || usageExcess < 0 + ? 0 + : attribute.unit === 'bytes' + ? formatBytes(usageExcess) + : usageExcess.toLocaleString()} +

+
+ )} +
+
+ + {attribute.additionalInfo?.(projectRef, subscription, usage)} + +
+

+ {attribute.chartPrefix || ''} + {attribute.name} per day +

+ {attribute.chartDescription.split('\n').map((paragraph, idx) => ( +

+ {paragraph} +

+ ))} +
+ {chartMeta[attribute.key].isLoading ? ( +
+ + + +
+ ) : chartData.length > 0 && notAllValuesZero ? ( + ChartYFormatterCompactNumber(value, attribute.unit)} + tooltipFormatter={(value) => + ChartTooltipValueFormatter(value, attribute.unit) + } + /> + ) : ( + + +
+ +

No data in period

+

May take up to 24 hours to show

+
+
+
+ )} + + ) : ( + + +
+
+

Not included in plan

+
+

+ You need to be on a higher plan in order to use this feature. +

+
+
+ + + + + +
+
+
+ )} +
+
+ ) + })} + + ) +} + +export default UsageSection diff --git a/studio/components/interfaces/Organization/index.ts b/studio/components/interfaces/Organization/index.ts index d37a3c1ca1d..c041402fdf2 100644 --- a/studio/components/interfaces/Organization/index.ts +++ b/studio/components/interfaces/Organization/index.ts @@ -4,6 +4,7 @@ import BillingSettings from './BillingSettings/BillingSettings' import BillingSettingsV2 from './BillingSettings/BillingSettingsV2' import InvoicesSettings from './InvoicesSettings/InvoicesSettings' import NewOrgForm from './NewOrg/NewOrgForm' +import Usage from './Usage/Usage' import OAuthApps from './OAuthApps/OAuthApps' export { @@ -13,5 +14,6 @@ export { BillingSettingsV2, InvoicesSettings, NewOrgForm, + Usage, OAuthApps, } diff --git a/studio/pages/org/[slug]/usage.tsx b/studio/pages/org/[slug]/usage.tsx index 4106cd88926..8826b1bae64 100644 --- a/studio/pages/org/[slug]/usage.tsx +++ b/studio/pages/org/[slug]/usage.tsx @@ -7,6 +7,7 @@ import { usePermissionsQuery } from 'data/permissions/permissions-query' import { useFlag, useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' import { Tabs } from 'ui' +import { Usage } from 'components/interfaces/Organization' const OrgUsage: NextPageWithLayout = () => { const { data: permissions } = usePermissionsQuery() @@ -20,9 +21,9 @@ const OrgUsage: NextPageWithLayout = () => { {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( ) : ( -
-
-
+
+
+

{selectedOrganization?.name ?? 'Organization'} settings

-

Billing V2

+
)} From 7d5096d3ca6d5aa68798964e7a1bd8376ae3d85c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 29 Jun 2023 17:54:37 +0800 Subject: [PATCH 03/21] Update UsageBarChart to accept a list of attributes, instead of a single one --- .../Organization/Usage/Bandwidth.tsx | 104 ++++++++++++++++-- .../Organization/Usage/Usage.constants.tsx | 26 +++-- .../interfaces/Organization/Usage/Usage.tsx | 45 ++++++-- .../Organization/Usage/UsageBarChart.tsx | 22 ++-- .../Organization/Usage/UsageSection.tsx | 36 +++--- .../Usage/UsageStackedBarChart.tsx | 0 6 files changed, 172 insertions(+), 61 deletions(-) create mode 100644 studio/components/interfaces/Organization/Usage/UsageStackedBarChart.tsx diff --git a/studio/components/interfaces/Organization/Usage/Bandwidth.tsx b/studio/components/interfaces/Organization/Usage/Bandwidth.tsx index ef46ec26dc3..ca2ead8f66a 100644 --- a/studio/components/interfaces/Organization/Usage/Bandwidth.tsx +++ b/studio/components/interfaces/Organization/Usage/Bandwidth.tsx @@ -3,6 +3,90 @@ import { useDailyStatsQuery } from 'data/analytics/daily-stats-query' import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' import UsageSection from './UsageSection' +const GB = 1073741824 +const MOCK_DATA = [ + { + id: '1', + loopId: '1', + period_start: '2023-06-29T07:05:12Z', + periodStartFormatted: '29 Jun 2023', + total_storage_egress: (1.5 * GB).toString(), + total_egress_modified: (1 * GB).toString(), + }, + { + id: '2', + loopId: '2', + period_start: '2023-06-30T07:05:12Z', + periodStartFormatted: '30 Jun 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (1.1 * GB).toString(), + }, + { + id: '3', + loopId: '3', + period_start: '2023-07-01T07:05:12Z', + periodStartFormatted: '01 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (1.2 * GB).toString(), + }, + { + id: '4', + loopId: '4', + period_start: '2023-07-02T07:05:12Z', + periodStartFormatted: '02 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (1.5 * GB).toString(), + }, + { + id: '5', + loopId: '5', + period_start: '2023-07-03T07:05:12Z', + periodStartFormatted: '03 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (2 * GB).toString(), + }, + { + id: '6', + loopId: '6', + period_start: '2023-07-04T07:05:12Z', + periodStartFormatted: '04 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (3 * GB).toString(), + }, + { + id: '7', + loopId: '7', + period_start: '2023-07-05T07:05:12Z', + periodStartFormatted: '05 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (4.5 * GB).toString(), + }, + { + id: '8', + loopId: '8', + period_start: '2023-07-06T07:05:12Z', + periodStartFormatted: '06 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (4 * GB).toString(), + }, + { + id: '9', + loopId: '9', + period_start: '2023-07-07T07:05:12Z', + periodStartFormatted: '07 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (3.8 * GB).toString(), + }, + { + id: '10', + loopId: '10', + period_start: '2023-07-08T07:05:12Z', + periodStartFormatted: '08 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (3.5 * GB).toString(), + }, +] + export interface BandwidthProps { projectRef: string startDate: string | undefined @@ -18,22 +102,22 @@ const Bandwidth = ({ endDate, currentBillingCycleSelected, }: BandwidthProps) => { - const { data: dbEgressData, isLoading: isLoadingDbEgressData } = useDailyStatsQuery({ - projectRef, - attribute: 'total_egress_modified', - interval: '1d', - startDate, - endDate, - }) + // const { data: dbEgressData, isLoading: isLoadingDbEgressData } = useDailyStatsQuery({ + // projectRef, + // attribute: 'total_egress_modified', + // interval: '1d', + // startDate, + // endDate, + // }) const chartMeta: { [key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean } } = { db_egress: { - data: dbEgressData?.data ?? [], + data: MOCK_DATA, // dbEgressData?.data ?? [], margin: 16, - isLoading: isLoadingDbEgressData, - hasNoData: dbEgressData?.hasNoData ?? false, + isLoading: false, // isLoadingDbEgressData, + hasNoData: false, // dbEgressData?.hasNoData ?? false, }, } diff --git a/studio/components/interfaces/Organization/Usage/Usage.constants.tsx b/studio/components/interfaces/Organization/Usage/Usage.constants.tsx index 0cbb438d5c2..d2db45b96b6 100644 --- a/studio/components/interfaces/Organization/Usage/Usage.constants.tsx +++ b/studio/components/interfaces/Organization/Usage/Usage.constants.tsx @@ -16,7 +16,7 @@ export const USAGE_STATUS = { export interface CategoryAttribute { anchor: string key: string // Property from project usage - attribute: string // For querying against stats-daily / infra-monitoring + attributes: { name: string; color: string }[] // For querying against stats-daily / infra-monitoring name: string chartPrefix?: string unit: 'bytes' | 'absolute' | 'percentage' @@ -42,6 +42,7 @@ export interface CategoryMeta { attributes: CategoryAttribute[] } +// [Joshen] We need to update this export const USAGE_CATEGORIES: CategoryMeta[] = [ { key: 'bandwidth', @@ -51,7 +52,10 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'dbEgress', key: 'db_egress', - attribute: 'total_egress_modified', + attributes: [ + { name: 'total_egress_modified', color: 'fill-green-1000' }, + { name: 'total_storage_egress', color: 'fill-blue-1000' }, + ], name: 'Total Egress', unit: 'bytes', description: @@ -68,7 +72,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'dbSize', key: 'db_size', - attribute: 'total_db_size_bytes', + attributes: [{ name: 'total_db_size_bytes', color: 'fill-scale-1200' }], name: 'Database size', chartPrefix: 'Average ', unit: 'bytes', @@ -150,7 +154,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'storageSize', key: 'storage_size', - attribute: 'total_storage_size_bytes', + attributes: [{ name: 'total_storage_size_bytes', color: 'fill-scale-1200' }], name: 'Storage Size', chartPrefix: 'Max ', unit: 'bytes', @@ -167,7 +171,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'funcCount', key: 'func_count', - attribute: 'total_func_count', + attributes: [{ name: 'total_func_count', color: 'fill-scale-1200' }], name: 'Edge Function Count', chartPrefix: 'Max ', unit: 'absolute', @@ -185,7 +189,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'mau', key: 'monthly_active_users', - attribute: 'total_auth_billing_period_mau', + attributes: [{ name: 'total_auth_billing_period_mau', color: 'fill-scale-1200' }], name: 'Monthly Active Users', unit: 'absolute', description: @@ -202,7 +206,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'mauSso', key: 'monthly_active_sso_users', - attribute: 'total_auth_billing_period_sso_mau', + attributes: [{ name: 'total_auth_billing_period_sso_mau', color: 'fill-scale-1200' }], name: 'Monthly Active SSO Users', unit: 'absolute', description: @@ -219,7 +223,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'storageImageTransformations', key: 'storage_image_render_count', - attribute: 'total_storage_image_render_count', + attributes: [{ name: 'total_storage_image_render_count', color: 'fill-scale-1200' }], name: 'Storage Image Transformations', unit: 'absolute', description: @@ -236,7 +240,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'functionInvocations', key: 'func_invocations', - attribute: 'total_func_invocations', + attributes: [{ name: 'total_func_invocations', color: 'fill-scale-1200' }], name: 'Edge Function Invocations', unit: 'absolute', description: @@ -252,7 +256,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'realtimeMessageCount', key: 'realtime_message_count', - attribute: 'total_realtime_message_count', + attributes: [{ name: 'total_realtime_message_count', color: 'fill-scale-1200' }], name: 'Realtime Message Count', unit: 'absolute', description: @@ -268,7 +272,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'realtimePeakConnection', key: 'realtime_peak_connection', - attribute: 'total_realtime_peak_connection', + attributes: [{ name: 'total_realtime_peak_connection', color: 'fill-scale-1200' }], name: 'Realtime Peak Connections', chartPrefix: 'Max ', unit: 'absolute', diff --git a/studio/components/interfaces/Organization/Usage/Usage.tsx b/studio/components/interfaces/Organization/Usage/Usage.tsx index 650b0cd0582..e6123655fd5 100644 --- a/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import dayjs from 'dayjs' import { useMemo, useState } from 'react' @@ -8,13 +9,12 @@ import DateRangePicker from 'components/to-be-cleaned/DateRangePicker' import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants' import SizeAndCounts from './SizeAndCounts' import Activity from './Activity' +import { IconLoader } from 'ui' const Usage = () => { const { slug } = useParams() const [dateRange, setDateRange] = useState() - const selectedProjectRef = 'kojplftaeiavuneadgix' - const { data: subscription, isLoading: isLoadingSubscription, @@ -22,6 +22,10 @@ const Usage = () => { isSuccess, } = useOrgSubscriptionQuery({ orgSlug: slug }) + const selectedProjectRef = 'kojplftaeiavuneadgix' + const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc() + const billingCycleEnd = dayjs.unix(subscription?.current_period_end ?? 0).utc() + const currentBillingCycleSelected = useMemo(() => { // Selected by default if (!dateRange?.period_start || !dateRange?.period_end || !subscription) return true @@ -61,16 +65,33 @@ const Usage = () => { return ( <> - +
+ {!isLoadingSubscription && ( + + )} + + {isLoadingSubscription ? ( + + ) : subscription !== undefined ? ( +
+

+ Organization is on the {subscription.plan.name} plan +

+

+ {billingCycleStart.format('DD MMM YYYY')} - {billingCycleEnd.format('DD MMM YYYY')} +

+
+ ) : null} +
{ const yMin = 0 // We can consider passing this as a prop if there's a use case in the future const yDomain = [yMin, yLimit ?? 0] - return (
@@ -65,9 +64,6 @@ const UsageBarChart = ({ return (
-

- {attribute === 'disk_io_budget' ? `Remaining IO budget:` : `${name}:`} -

{dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? (

No data yet

) : ( @@ -83,11 +79,13 @@ const UsageBarChart = ({ } else return null }} /> - - {data.map((entry) => { - return - })} - + {attributes?.map((attr) => ( + + {data.map((entry) => { + return + })} + + ))}
diff --git a/studio/components/interfaces/Organization/Usage/UsageSection.tsx b/studio/components/interfaces/Organization/Usage/UsageSection.tsx index 9b67c1fd239..300507922c7 100644 --- a/studio/components/interfaces/Organization/Usage/UsageSection.tsx +++ b/studio/components/interfaces/Organization/Usage/UsageSection.tsx @@ -1,28 +1,29 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import clsx from 'clsx' +import Link from 'next/link' + +import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants' +import Panel from 'components/ui/Panel' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import SectionHeader from './SectionHeader' -import { CategoryMetaKey, USAGE_CATEGORIES } from './Usage.constants' +import SparkBar from 'components/ui/SparkBar' +import { DataPoint } from 'data/analytics/constants' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' import { ProjectUsageResponse, UsageMetric, useProjectUsageQuery, } from 'data/usage/project-usage-query' -import { DataPoint } from 'data/analytics/constants' -import SectionContent from './SectionContent' +import { formatBytes } from 'lib/helpers' import { Button, IconAlertTriangle, IconBarChart2 } from 'ui' -import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants' -import Link from 'next/link' -import SparkBar from 'components/ui/SparkBar' -import clsx from 'clsx' -import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' +import SectionContent from './SectionContent' +import SectionHeader from './SectionHeader' +import { CategoryMetaKey, USAGE_CATEGORIES } from './Usage.constants' import { ChartTooltipValueFormatter, ChartYFormatterCompactNumber, getUpgradeUrl, } from './Usage.utils' -import { formatBytes } from 'lib/helpers' import UsageBarChart from './UsageBarChart' -import Panel from 'components/ui/Panel' -import * as Tooltip from '@radix-ui/react-tooltip' interface UsageSectionProps { projectRef: string @@ -63,9 +64,12 @@ const UsageSection = ({ const chartData = chartMeta[attribute.key]?.data ?? [] - const notAllValuesZero = chartData.some( - (dataPoint) => Number(dataPoint[attribute.attribute]) !== 0 - ) + const notAllValuesZero = + attribute.attributes + ?.map((attr) => { + return chartData.some((dataPoint) => Number(dataPoint[attr.name]) !== 0) + }) + .some((x) => !!x) ?? false return (
@@ -229,7 +233,7 @@ const UsageSection = ({ ChartYFormatterCompactNumber(value, attribute.unit)} diff --git a/studio/components/interfaces/Organization/Usage/UsageStackedBarChart.tsx b/studio/components/interfaces/Organization/Usage/UsageStackedBarChart.tsx new file mode 100644 index 00000000000..e69de29bb2d From 1a360a8e8ec766e39cd67f69b5c12f727f75eb7d Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 29 Jun 2023 20:44:54 +0800 Subject: [PATCH 04/21] midway --- .../Organization/Usage/Bandwidth.tsx | 4 +- .../Organization/Usage/Usage.constants.tsx | 26 +++---- .../Organization/Usage/UsageBarChart.tsx | 78 ++++++++++++++++--- .../Organization/Usage/UsageSection.tsx | 2 +- 4 files changed, 85 insertions(+), 25 deletions(-) diff --git a/studio/components/interfaces/Organization/Usage/Bandwidth.tsx b/studio/components/interfaces/Organization/Usage/Bandwidth.tsx index ca2ead8f66a..ac8aa48591e 100644 --- a/studio/components/interfaces/Organization/Usage/Bandwidth.tsx +++ b/studio/components/interfaces/Organization/Usage/Bandwidth.tsx @@ -10,8 +10,8 @@ const MOCK_DATA = [ loopId: '1', period_start: '2023-06-29T07:05:12Z', periodStartFormatted: '29 Jun 2023', - total_storage_egress: (1.5 * GB).toString(), - total_egress_modified: (1 * GB).toString(), + total_storage_egress: (1.53 * GB).toString(), + total_egress_modified: (1.12 * GB).toString(), }, { id: '2', diff --git a/studio/components/interfaces/Organization/Usage/Usage.constants.tsx b/studio/components/interfaces/Organization/Usage/Usage.constants.tsx index d2db45b96b6..e70df805f7f 100644 --- a/studio/components/interfaces/Organization/Usage/Usage.constants.tsx +++ b/studio/components/interfaces/Organization/Usage/Usage.constants.tsx @@ -16,7 +16,7 @@ export const USAGE_STATUS = { export interface CategoryAttribute { anchor: string key: string // Property from project usage - attributes: { name: string; color: string }[] // For querying against stats-daily / infra-monitoring + attributes: { key: string; name?: string; color: 'white' | 'blue' | 'green' }[] // For querying against stats-daily / infra-monitoring, maybe can type the color name: string chartPrefix?: string unit: 'bytes' | 'absolute' | 'percentage' @@ -51,10 +51,10 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ attributes: [ { anchor: 'dbEgress', - key: 'db_egress', + key: 'db_egress', // This needs to be updated based on the new org usage endpoint attributes: [ - { name: 'total_egress_modified', color: 'fill-green-1000' }, - { name: 'total_storage_egress', color: 'fill-blue-1000' }, + { key: 'total_egress_modified', name: 'DB Egress', color: 'green' }, + { key: 'total_storage_egress', name: 'Storage Egress', color: 'blue' }, ], name: 'Total Egress', unit: 'bytes', @@ -72,7 +72,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'dbSize', key: 'db_size', - attributes: [{ name: 'total_db_size_bytes', color: 'fill-scale-1200' }], + attributes: [{ key: 'total_db_size_bytes', color: 'white' }], name: 'Database size', chartPrefix: 'Average ', unit: 'bytes', @@ -154,7 +154,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'storageSize', key: 'storage_size', - attributes: [{ name: 'total_storage_size_bytes', color: 'fill-scale-1200' }], + attributes: [{ key: 'total_storage_size_bytes', color: 'white' }], name: 'Storage Size', chartPrefix: 'Max ', unit: 'bytes', @@ -171,7 +171,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'funcCount', key: 'func_count', - attributes: [{ name: 'total_func_count', color: 'fill-scale-1200' }], + attributes: [{ key: 'total_func_count', color: 'white' }], name: 'Edge Function Count', chartPrefix: 'Max ', unit: 'absolute', @@ -189,7 +189,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'mau', key: 'monthly_active_users', - attributes: [{ name: 'total_auth_billing_period_mau', color: 'fill-scale-1200' }], + attributes: [{ key: 'total_auth_billing_period_mau', color: 'white' }], name: 'Monthly Active Users', unit: 'absolute', description: @@ -206,7 +206,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'mauSso', key: 'monthly_active_sso_users', - attributes: [{ name: 'total_auth_billing_period_sso_mau', color: 'fill-scale-1200' }], + attributes: [{ key: 'total_auth_billing_period_sso_mau', color: 'white' }], name: 'Monthly Active SSO Users', unit: 'absolute', description: @@ -223,7 +223,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'storageImageTransformations', key: 'storage_image_render_count', - attributes: [{ name: 'total_storage_image_render_count', color: 'fill-scale-1200' }], + attributes: [{ key: 'total_storage_image_render_count', color: 'white' }], name: 'Storage Image Transformations', unit: 'absolute', description: @@ -240,7 +240,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'functionInvocations', key: 'func_invocations', - attributes: [{ name: 'total_func_invocations', color: 'fill-scale-1200' }], + attributes: [{ key: 'total_func_invocations', color: 'white' }], name: 'Edge Function Invocations', unit: 'absolute', description: @@ -256,7 +256,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'realtimeMessageCount', key: 'realtime_message_count', - attributes: [{ name: 'total_realtime_message_count', color: 'fill-scale-1200' }], + attributes: [{ key: 'total_realtime_message_count', color: 'white' }], name: 'Realtime Message Count', unit: 'absolute', description: @@ -272,7 +272,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ { anchor: 'realtimePeakConnection', key: 'realtime_peak_connection', - attributes: [{ name: 'total_realtime_peak_connection', color: 'fill-scale-1200' }], + attributes: [{ key: 'total_realtime_peak_connection', color: 'white' }], name: 'Realtime Peak Connections', chartPrefix: 'Max ', unit: 'absolute', diff --git a/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx b/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx index c5e72e29f15..834442651c6 100644 --- a/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx +++ b/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx @@ -10,14 +10,24 @@ import { YAxis, } from 'recharts' import dayjs from 'dayjs' +import clsx from 'clsx' // [Joshen] This BarChart is specifically for usage, hence not a reusable component, and not // replacing the existing BarChart in ui/Charts +// [Joshen] There's shit ton of clean up to do here for org billing because we were copying over from project usage +// Please do so properly to prevent any confusion - priority on maintainability + +const COLOR_MAP = { + white: { bar: 'fill-scale-1200', marker: 'bg-scale-1200' }, + green: { bar: 'fill-green-1000', marker: 'bg-green-1000' }, + blue: { bar: 'fill-blue-1000', marker: 'bg-blue-1000' }, +} + export interface UsageBarChartProps { data: DataPoint[] name: string // Used within the tooltip - attributes: { name: string; color: string }[] + attributes: { key: string; name?: string; color: 'white' | 'blue' | 'green' }[] // [JOSHEN TODO] Extract as type with Usage.constants unit: 'bytes' | 'absolute' | 'percentage' yLimit?: number yLeftMargin?: number @@ -55,6 +65,8 @@ const UsageBarChart = ({ { const { active, payload } = props + + // [JOSHEN TODO] Refactor this, please do away with single attribute logics if (active && payload && payload.length) { const dataPeriod = dayjs(payload[0].payload.period_start) const value = @@ -63,13 +75,61 @@ const UsageBarChart = ({ : Number(payload[0].value) return ( -
- {dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? ( -

No data yet

+
1 ? 'w-[250px]' : 'w-[170px]' + )} + > + {attributes.length > 1 ? ( + <> + {dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? ( +

No data yet

+ ) : ( +
+ {attributes.map((attr) => { + const attrMeta = payload.find((x) => x.dataKey === attr.key) + const attrValue = Number(attrMeta?.value ?? 0) + const sumValue = payload.reduce((a, b) => a + Number(b.value), 0) + const percentageContribution = ((attrValue / sumValue) * 100).toFixed( + 1 + ) + + return ( +
+
+
+

+ {attr.name} ({percentageContribution}%):{' '} +

+
+

+ {tooltipFormatter !== undefined + ? tooltipFormatter(attrValue) + : attrValue} +

+
+ ) + })} +
+ )} + ) : ( -

- {tooltipFormatter !== undefined ? tooltipFormatter(value) : value} -

+ <> +

{name}

+ {dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? ( +

No data yet

+ ) : ( +

+ {tooltipFormatter !== undefined ? tooltipFormatter(value) : value} +

+ )} + )}

{dataPeriod.format('DD MMM YYYY')} @@ -80,9 +140,9 @@ const UsageBarChart = ({ }} /> {attributes?.map((attr) => ( - + {data.map((entry) => { - return + return })} ))} diff --git a/studio/components/interfaces/Organization/Usage/UsageSection.tsx b/studio/components/interfaces/Organization/Usage/UsageSection.tsx index 300507922c7..b8e4295bc67 100644 --- a/studio/components/interfaces/Organization/Usage/UsageSection.tsx +++ b/studio/components/interfaces/Organization/Usage/UsageSection.tsx @@ -67,7 +67,7 @@ const UsageSection = ({ const notAllValuesZero = attribute.attributes ?.map((attr) => { - return chartData.some((dataPoint) => Number(dataPoint[attr.name]) !== 0) + return chartData.some((dataPoint) => Number(dataPoint[attr.key]) !== 0) }) .some((x) => !!x) ?? false From 71592b814a2c980ab3d6b70a41f7cd03d806593d Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 29 Jun 2023 22:40:10 +0800 Subject: [PATCH 05/21] A bit of clean up --- .../Organization/Usage/Bandwidth.tsx | 1 + .../Organization/Usage/Usage.constants.tsx | 15 +++- .../Organization/Usage/UsageBarChart.tsx | 84 ++++--------------- .../Organization/Usage/UsageChartTooltips.tsx | 80 ++++++++++++++++++ .../Organization/Usage/UsageSection.tsx | 1 + studio/pages/org/[slug]/billing.tsx | 4 +- 6 files changed, 114 insertions(+), 71 deletions(-) create mode 100644 studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx diff --git a/studio/components/interfaces/Organization/Usage/Bandwidth.tsx b/studio/components/interfaces/Organization/Usage/Bandwidth.tsx index ac8aa48591e..36635299f93 100644 --- a/studio/components/interfaces/Organization/Usage/Bandwidth.tsx +++ b/studio/components/interfaces/Organization/Usage/Bandwidth.tsx @@ -95,6 +95,7 @@ export interface BandwidthProps { currentBillingCycleSelected: boolean } +// [Joshen TODO] Needs to take in org slug and eventually use daily stats org query const Bandwidth = ({ projectRef, subscription, diff --git a/studio/components/interfaces/Organization/Usage/Usage.constants.tsx b/studio/components/interfaces/Organization/Usage/Usage.constants.tsx index e70df805f7f..533dc73cffc 100644 --- a/studio/components/interfaces/Organization/Usage/Usage.constants.tsx +++ b/studio/components/interfaces/Organization/Usage/Usage.constants.tsx @@ -5,6 +5,12 @@ import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscrip import { ProjectUsageData } from 'data/usage/project-usage-query' import { Alert, Badge, Button, IconExternalLink } from 'ui' +export const COLOR_MAP = { + white: { bar: 'fill-scale-1200', marker: 'bg-scale-1200' }, + green: { bar: 'fill-green-1000', marker: 'bg-green-1000' }, + blue: { bar: 'fill-blue-1000', marker: 'bg-blue-1000' }, +} + export const Y_DOMAIN_CEILING_MULTIPLIER = 4 / 3 export const USAGE_STATUS = { @@ -13,10 +19,15 @@ export const USAGE_STATUS = { EXCEEDED: 'EXCEEDED', } +export interface Attribute { + key: string + name?: string + color: 'white' | 'blue' | 'green' +} export interface CategoryAttribute { anchor: string key: string // Property from project usage - attributes: { key: string; name?: string; color: 'white' | 'blue' | 'green' }[] // For querying against stats-daily / infra-monitoring, maybe can type the color + attributes: Attribute[] // For querying against stats-daily / infra-monitoring name: string chartPrefix?: string unit: 'bytes' | 'absolute' | 'percentage' @@ -42,7 +53,7 @@ export interface CategoryMeta { attributes: CategoryAttribute[] } -// [Joshen] We need to update this +// [Joshen TODO] We need to update this export const USAGE_CATEGORIES: CategoryMeta[] = [ { key: 'bandwidth', diff --git a/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx b/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx index 834442651c6..337f43e6edd 100644 --- a/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx +++ b/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx @@ -11,23 +11,16 @@ import { } from 'recharts' import dayjs from 'dayjs' import clsx from 'clsx' +import { COLOR_MAP, Attribute } from './Usage.constants' +import { MultiAttributeTooltipContent, SingleAttributeTooltipContent } from './UsageChartTooltips' // [Joshen] This BarChart is specifically for usage, hence not a reusable component, and not // replacing the existing BarChart in ui/Charts -// [Joshen] There's shit ton of clean up to do here for org billing because we were copying over from project usage -// Please do so properly to prevent any confusion - priority on maintainability - -const COLOR_MAP = { - white: { bar: 'fill-scale-1200', marker: 'bg-scale-1200' }, - green: { bar: 'fill-green-1000', marker: 'bg-green-1000' }, - blue: { bar: 'fill-blue-1000', marker: 'bg-blue-1000' }, -} - export interface UsageBarChartProps { data: DataPoint[] name: string // Used within the tooltip - attributes: { key: string; name?: string; color: 'white' | 'blue' | 'green' }[] // [JOSHEN TODO] Extract as type with Usage.constants + attributes: Attribute[] unit: 'bytes' | 'absolute' | 'percentage' yLimit?: number yLeftMargin?: number @@ -46,7 +39,6 @@ const UsageBarChart = ({ tooltipFormatter, }: UsageBarChartProps) => { const yMin = 0 // We can consider passing this as a prop if there's a use case in the future - const yDomain = [yMin, yLimit ?? 0] return ( @@ -65,71 +57,31 @@ const UsageBarChart = ({ { const { active, payload } = props - - // [JOSHEN TODO] Refactor this, please do away with single attribute logics if (active && payload && payload.length) { const dataPeriod = dayjs(payload[0].payload.period_start) - const value = - unit === 'percentage' - ? Number(payload[0].value).toFixed(2) - : Number(payload[0].value) - + const isAfterToday = dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) return (

1 ? 'w-[250px]' : 'w-[170px]' + attributes.length > 1 && !isAfterToday ? 'w-[250px]' : 'w-[170px]' )} > {attributes.length > 1 ? ( - <> - {dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? ( -

No data yet

- ) : ( -
- {attributes.map((attr) => { - const attrMeta = payload.find((x) => x.dataKey === attr.key) - const attrValue = Number(attrMeta?.value ?? 0) - const sumValue = payload.reduce((a, b) => a + Number(b.value), 0) - const percentageContribution = ((attrValue / sumValue) * 100).toFixed( - 1 - ) - - return ( -
-
-
-

- {attr.name} ({percentageContribution}%):{' '} -

-
-

- {tooltipFormatter !== undefined - ? tooltipFormatter(attrValue) - : attrValue} -

-
- ) - })} -
- )} - + ) : ( - <> -

{name}

- {dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? ( -

No data yet

- ) : ( -

- {tooltipFormatter !== undefined ? tooltipFormatter(value) : value} -

- )} - + )}

{dataPeriod.format('DD MMM YYYY')} diff --git a/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx b/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx new file mode 100644 index 00000000000..3bddb5f962e --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx @@ -0,0 +1,80 @@ +import clsx from 'clsx' +import { Payload, ValueType } from 'recharts/types/component/DefaultTooltipContent' +import { Attribute, COLOR_MAP } from './Usage.constants' + +export interface SingleAttributeTooltipContentProps { + name: string + unit: 'bytes' | 'percentage' | 'absolute' + value: any + isAfterToday: boolean + tooltipFormatter?: (value: any) => any +} + +export const SingleAttributeTooltipContent = ({ + name, + unit, + value, + isAfterToday, + tooltipFormatter, +}: SingleAttributeTooltipContentProps) => { + const formattedValue = unit === 'percentage' ? Number(value).toFixed(2) : Number(value) + return ( + <> +

{name}

+ {isAfterToday ? ( +

No data yet

+ ) : ( +

+ {tooltipFormatter !== undefined ? tooltipFormatter(formattedValue) : formattedValue} +

+ )} + + ) +} + +export interface MultiAttributeTooltipContentProps { + attributes: Attribute[] + values: Payload[] + isAfterToday: boolean + tooltipFormatter?: (value: any) => any +} + +export const MultiAttributeTooltipContent = ({ + attributes, + values, + isAfterToday, + tooltipFormatter, +}: MultiAttributeTooltipContentProps) => { + return ( + <> + {isAfterToday ? ( +

No data yet

+ ) : ( +
+ {attributes.map((attr) => { + const attrMeta = values.find((x) => x.dataKey === attr.key) + const attrValue = Number(attrMeta?.value ?? 0) + const sumValue = values.reduce((a, b) => a + Number(b.value), 0) + const percentageContribution = ((attrValue / sumValue) * 100).toFixed(1) + + return ( +
+
+
+

+ {attr.name} ({percentageContribution}%):{' '} +

+
+

+ {tooltipFormatter !== undefined ? tooltipFormatter(attrValue) : attrValue} +

+
+ ) + })} +
+ )} + + ) +} diff --git a/studio/components/interfaces/Organization/Usage/UsageSection.tsx b/studio/components/interfaces/Organization/Usage/UsageSection.tsx index b8e4295bc67..65b67783c21 100644 --- a/studio/components/interfaces/Organization/Usage/UsageSection.tsx +++ b/studio/components/interfaces/Organization/Usage/UsageSection.tsx @@ -35,6 +35,7 @@ interface UsageSectionProps { currentBillingCycleSelected: boolean } +// [Joshen TODO] Needs to change to use org slug, and eventually call useOrganizationUsageQuery const UsageSection = ({ projectRef, categoryKey, diff --git a/studio/pages/org/[slug]/billing.tsx b/studio/pages/org/[slug]/billing.tsx index c91ad14096b..6ffb53b753b 100644 --- a/studio/pages/org/[slug]/billing.tsx +++ b/studio/pages/org/[slug]/billing.tsx @@ -17,8 +17,6 @@ const OrgBillingSettings: NextPageWithLayout = () => { const { isLoading: isLoadingPermissions } = usePermissionsQuery() const showOAuthApps = useFlag('oauthApps') - const orgCreationV2 = useFlag('orgcreationv2') - const isOrgBilling = !!selectedOrganization?.subscription_id return ( @@ -43,7 +41,7 @@ const OrgBillingSettings: NextPageWithLayout = () => { - {orgCreationV2 && } + {isOrgBilling && } {showOAuthApps && } From 0ed5e40c4c9ad3cbd6342f5ec6c03bfeca4f353f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 30 Jun 2023 15:56:05 +0800 Subject: [PATCH 06/21] Add and refactor the other components to org billing for billing v2 --- .../BillingSettings/BillingEmail.tsx | 1 + .../BillingSettings/BillingSettingsV2.tsx | 19 - .../CostControl/CostControl.tsx | 151 ------ .../BillingAddress.constants.ts | 248 ++++++++++ .../BillingAddress/BillingAddress.tsx | 180 +++++++ .../BillingSettingsV2/BillingEmail.tsx | 130 +++++ .../BillingSettingsV2/BillingSettings.tsx | 37 ++ .../CostControl/CostControl.tsx | 140 ++++++ .../BillingSettingsV2/CreditBalance.tsx | 48 ++ .../BillingSettingsV2/Scaffold.tsx | 5 + .../Subscription/README.md | 0 .../Subscription/Subscription.tsx | 60 ++- .../TaxID/TaxID.constants.ts | 459 ++++++++++++++++++ .../BillingSettingsV2/TaxID/TaxID.tsx | 233 +++++++++ .../BillingSettingsV2/TaxID/TaxID.utilts.ts | 24 + .../interfaces/Organization/index.ts | 3 +- studio/components/ui/Forms/FormSection.tsx | 4 +- studio/data/organizations/keys.ts | 1 + .../organization-customer-profile-query.ts | 9 +- ...zation-customer-profile-update-mutation.ts | 58 +++ .../organization-tax-ids-query.ts | 68 +++ .../organization-tax-ids-update-mutation.ts | 82 ++++ 22 files changed, 1754 insertions(+), 206 deletions(-) delete mode 100644 studio/components/interfaces/Organization/BillingSettings/BillingSettingsV2.tsx delete mode 100644 studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.constants.ts create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/Scaffold.tsx rename studio/components/interfaces/Organization/{BillingSettings => BillingSettingsV2}/Subscription/README.md (100%) rename studio/components/interfaces/Organization/{BillingSettings => BillingSettingsV2}/Subscription/Subscription.tsx (81%) create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.constants.ts create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.utilts.ts create mode 100644 studio/data/organizations/organization-customer-profile-update-mutation.ts create mode 100644 studio/data/organizations/organization-tax-ids-query.ts create mode 100644 studio/data/organizations/organization-tax-ids-update-mutation.ts diff --git a/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index 42ecf17386e..98438a7f4df 100644 --- a/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -75,6 +75,7 @@ const BillingEmail = () => { useEffect(() => { const values = { billing_email: billing_email ?? '' } resetForm({ values, initialValues: values }) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [slug]) return ( diff --git a/studio/components/interfaces/Organization/BillingSettings/BillingSettingsV2.tsx b/studio/components/interfaces/Organization/BillingSettings/BillingSettingsV2.tsx deleted file mode 100644 index 8a3a736386f..00000000000 --- a/studio/components/interfaces/Organization/BillingSettings/BillingSettingsV2.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import CostControl from './CostControl/CostControl' -import Subscription from './Subscription/Subscription' - -const BillingSettingsV2 = () => { - return ( - <> -
- -
-
-
- -
-
- - ) -} - -export default BillingSettingsV2 diff --git a/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx deleted file mode 100644 index cc6641c7014..00000000000 --- a/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import Link from 'next/link' - -import { useParams, useTheme } from 'common' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { BASE_PATH } from 'lib/constants' -import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' -import { Alert, Button, IconExternalLink } from 'ui' -// import SpendCapSidePanel from './SpendCapSidePanel' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useFlag } from 'hooks' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' - -export interface CostControlProps {} - -const CostControl = ({}: CostControlProps) => { - const { slug } = useParams() - const snap = useSubscriptionPageStateSnapshot() - const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') - const { isDarkMode } = useTheme() - - const { data: subscription, isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) - - const currentPlan = subscription?.plan - const isUsageBillingEnabled = subscription?.usage_billing_enabled ?? false - - const canChangeTier = - !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '') - - return ( - <> -
-
-
-
-
-

Cost Control

-

- Control whether to use beyond your plans included quota -

-
-
-

More information

- -
-
-
-
- {isLoading ? ( -
- - - -
- ) : ( -
- {['team', 'enterprise'].includes(currentPlan?.id || '') ? ( - - {currentPlan?.name || ''} plan requires you to have spend cap off at all times. Your - projects will never become unresponsive. Only when your{' '} - - - included usage - - {' '} - is exceeded will you be charged for any additional usage. - - ) : ( -

- You can control whether your organization is charged for additional usage beyond the{' '} - - - included quota - - {' '} - of your subscription plan. If you need to go beyond the included quota, simply - switch off your spend cap to pay for additional usage. -

- )} - -
-
-
- Spend Cap -
-
-
-

- Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'} -

-

- {isUsageBillingEnabled ? ( - You will be charged for any usage above the included quota. - ) : ( - - You won't be charged any extra for usage. However, your projects could become - unresponsive or enter read only mode if you exceed the included quota. - - )} -

- {isUsageBillingEnabled && ( -

- Your projects will never become unresponsive. Only when your usage reaches the - quota limit will you be charged for any excess usage. -

- )} - - - -
-
-
- )} -
- - {/* */} - - ) -} - -export default CostControl diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.constants.ts b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.constants.ts new file mode 100644 index 00000000000..728b4ecef37 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.constants.ts @@ -0,0 +1,248 @@ +export const COUNTRIES = [ + { code: 'AF', name: 'Afghanistan' }, + { code: 'AL', name: 'Albania' }, + { code: 'DZ', name: 'Algeria' }, + { code: 'AS', name: 'American Samoa' }, + { code: 'AD', name: 'Andorra' }, + { code: 'AO', name: 'Angola' }, + { code: 'AI', name: 'Anguilla' }, + { code: 'AQ', name: 'Antarctica' }, + { code: 'AG', name: 'Antigua and Barbuda' }, + { code: 'AR', name: 'Argentina' }, + { code: 'AM', name: 'Armenia' }, + { code: 'AW', name: 'Aruba' }, + { code: 'AU', name: 'Australia' }, + { code: 'AT', name: 'Austria' }, + { code: 'AZ', name: 'Azerbaijan' }, + { code: 'BS', name: 'Bahamas' }, + { code: 'BH', name: 'Bahrain' }, + { code: 'BD', name: 'Bangladesh' }, + { code: 'BB', name: 'Barbados' }, + { code: 'BY', name: 'Belarus' }, + { code: 'BE', name: 'Belgium' }, + { code: 'BZ', name: 'Belize' }, + { code: 'BJ', name: 'Benin' }, + { code: 'BM', name: 'Bermuda' }, + { code: 'BT', name: 'Bhutan' }, + { code: 'BO', name: 'Bolivia' }, + { code: 'BA', name: 'Bosnia and Herzegovina' }, + { code: 'BW', name: 'Botswana' }, + { code: 'BV', name: 'Bouvet Island' }, + { code: 'BR', name: 'Brazil' }, + { code: 'IO', name: 'British Indian Ocean Territory' }, + { code: 'VG', name: 'British Virgin Islands' }, + { code: 'BN', name: 'Brunei Darussalam' }, + { code: 'BG', name: 'Bulgaria' }, + { code: 'BF', name: 'Burkina Faso' }, + { code: 'BI', name: 'Burundi' }, + { code: 'KH', name: 'Cambodia' }, + { code: 'CM', name: 'Cameroon' }, + { code: 'CA', name: 'Canada' }, + { code: 'CV', name: 'Cape Verde' }, + { code: 'KY', name: 'Cayman Islands' }, + { code: 'CF', name: 'Central African Republic' }, + { code: 'TD', name: 'Chad' }, + { code: 'CL', name: 'Chile' }, + { code: 'CN', name: 'China' }, + { code: 'CX', name: 'Christmas Island' }, + { code: 'CC', name: 'Cocos (Keeling) Islands' }, + { code: 'CO', name: 'Colombia' }, + { code: 'KM', name: 'Comoros' }, + { code: 'CG', name: 'Congo' }, + { code: 'CK', name: 'Cook Islands' }, + { code: 'CR', name: 'Costa Rica' }, + { code: 'HR', name: 'Croatia' }, + { code: 'CU', name: 'Cuba' }, + { code: 'CW', name: 'Curaçao' }, + { code: 'CY', name: 'Cyprus' }, + { code: 'CZ', name: 'Czech Republic' }, + { code: 'CI', name: "Côte d'Ivoire" }, + { code: 'DK', name: 'Denmark' }, + { code: 'DJ', name: 'Djibouti' }, + { code: 'DM', name: 'Dominica' }, + { code: 'DO', name: 'Dominican Republic' }, + { code: 'EC', name: 'Ecuador' }, + { code: 'EG', name: 'Egypt' }, + { code: 'SV', name: 'El Salvador' }, + { code: 'GQ', name: 'Equatorial Guinea' }, + { code: 'ER', name: 'Eritrea' }, + { code: 'EE', name: 'Estonia' }, + { code: 'SZ', name: 'Eswatini' }, + { code: 'ET', name: 'Ethiopia' }, + { code: 'FK', name: 'Falkland Islands' }, + { code: 'FO', name: 'Faroe Islands' }, + { code: 'FJ', name: 'Fiji' }, + { code: 'FI', name: 'Finland' }, + { code: 'FR', name: 'France' }, + { code: 'GF', name: 'French Guiana' }, + { code: 'PF', name: 'French Polynesia' }, + { code: 'TF', name: 'French Southern Territories' }, + { code: 'GA', name: 'Gabon' }, + { code: 'GM', name: 'Gambia' }, + { code: 'GE', name: 'Georgia' }, + { code: 'DE', name: 'Germany' }, + { code: 'GH', name: 'Ghana' }, + { code: 'GI', name: 'Gibraltar' }, + { code: 'GR', name: 'Greece' }, + { code: 'GL', name: 'Greenland' }, + { code: 'GD', name: 'Grenada' }, + { code: 'GP', name: 'Guadeloupe' }, + { code: 'GU', name: 'Guam' }, + { code: 'GT', name: 'Guatemala' }, + { code: 'GG', name: 'Guernsey' }, + { code: 'GN', name: 'Guinea' }, + { code: 'GW', name: 'Guinea-Bissau' }, + { code: 'GY', name: 'Guyana' }, + { code: 'HT', name: 'Haiti' }, + { code: 'HM', name: 'Heard & McDonald Islands' }, + { code: 'HN', name: 'Honduras' }, + { code: 'HK', name: 'Hong Kong' }, + { code: 'HU', name: 'Hungary' }, + { code: 'IS', name: 'Iceland' }, + { code: 'IN', name: 'India' }, + { code: 'ID', name: 'Indonesia' }, + { code: 'IR', name: 'Iran' }, + { code: 'IQ', name: 'Iraq' }, + { code: 'IE', name: 'Ireland' }, + { code: 'IM', name: 'Isle of Man' }, + { code: 'IL', name: 'Israel' }, + { code: 'IT', name: 'Italy' }, + { code: 'JM', name: 'Jamaica' }, + { code: 'JP', name: 'Japan' }, + { code: 'JE', name: 'Jersey' }, + { code: 'JO', name: 'Jordan' }, + { code: 'KZ', name: 'Kazakhstan' }, + { code: 'KE', name: 'Kenya' }, + { code: 'KI', name: 'Kiribati' }, + { code: 'KW', name: 'Kuwait' }, + { code: 'KG', name: 'Kyrgyzstan' }, + { code: 'LA', name: 'Laos' }, + { code: 'LV', name: 'Latvia' }, + { code: 'LB', name: 'Lebanon' }, + { code: 'LS', name: 'Lesotho' }, + { code: 'LR', name: 'Liberia' }, + { code: 'LY', name: 'Libya' }, + { code: 'LI', name: 'Liechtenstein' }, + { code: 'LT', name: 'Lithuania' }, + { code: 'LU', name: 'Luxembourg' }, + { code: 'MO', name: 'Macao' }, + { code: 'MG', name: 'Madagascar' }, + { code: 'MW', name: 'Malawi' }, + { code: 'MY', name: 'Malaysia' }, + { code: 'MV', name: 'Maldives' }, + { code: 'ML', name: 'Mali' }, + { code: 'MT', name: 'Malta' }, + { code: 'MH', name: 'Marshall Islands' }, + { code: 'MQ', name: 'Martinique' }, + { code: 'MR', name: 'Mauritania' }, + { code: 'MU', name: 'Mauritius' }, + { code: 'YT', name: 'Mayotte' }, + { code: 'MX', name: 'Mexico' }, + { code: 'FM', name: 'Micronesia' }, + { code: 'MD', name: 'Moldova' }, + { code: 'MC', name: 'Monaco' }, + { code: 'MN', name: 'Mongolia' }, + { code: 'ME', name: 'Montenegro' }, + { code: 'MS', name: 'Montserrat' }, + { code: 'MA', name: 'Morocco' }, + { code: 'MZ', name: 'Mozambique' }, + { code: 'MM', name: 'Myanmar' }, + { code: 'NA', name: 'Namibia' }, + { code: 'NR', name: 'Nauru' }, + { code: 'NP', name: 'Nepal' }, + { code: 'NL', name: 'Netherlands' }, + { code: 'NC', name: 'New Caledonia' }, + { code: 'NZ', name: 'New Zealand' }, + { code: 'NI', name: 'Nicaragua' }, + { code: 'NE', name: 'Niger' }, + { code: 'NG', name: 'Nigeria' }, + { code: 'NU', name: 'Niue' }, + { code: 'NF', name: 'Norfolk Island' }, + { code: 'KP', name: 'North Korea' }, + { code: 'MK', name: 'North Macedonia' }, + { code: 'MP', name: 'Northern Mariana Islands' }, + { code: 'NO', name: 'Norway' }, + { code: 'OM', name: 'Oman' }, + { code: 'PK', name: 'Pakistan' }, + { code: 'PW', name: 'Palau' }, + { code: 'PS', name: 'Palestinian Territories' }, + { code: 'PA', name: 'Panama' }, + { code: 'PG', name: 'Papua New Guinea' }, + { code: 'PY', name: 'Paraguay' }, + { code: 'PE', name: 'Peru' }, + { code: 'PH', name: 'Philippines' }, + { code: 'PN', name: 'Pitcairn Islands' }, + { code: 'PL', name: 'Poland' }, + { code: 'PT', name: 'Portugal' }, + { code: 'PR', name: 'Puerto Rico' }, + { code: 'QA', name: 'Qatar' }, + { code: 'RO', name: 'Romania' }, + { code: 'RU', name: 'Russia' }, + { code: 'RW', name: 'Rwanda' }, + { code: 'RE', name: 'Réunion' }, + { code: 'BL', name: 'Saint Barthélemy' }, + { code: 'WS', name: 'Samoa' }, + { code: 'SM', name: 'San Marino' }, + { code: 'ST', name: 'Sao Tome and Principe' }, + { code: 'SA', name: 'Saudi Arabia' }, + { code: 'SN', name: 'Senegal' }, + { code: 'RS', name: 'Serbia' }, + { code: 'SC', name: 'Seychelles' }, + { code: 'SL', name: 'Sierra Leone' }, + { code: 'SG', name: 'Singapore' }, + { code: 'SX', name: 'Sint Maarten' }, + { code: 'SK', name: 'Slovakia' }, + { code: 'SI', name: 'Slovenia' }, + { code: 'SB', name: 'Solomon Islands' }, + { code: 'SO', name: 'Somalia' }, + { code: 'ZA', name: 'South Africa' }, + { code: 'GS', name: 'South Georgia and the South Sandwich Islands' }, + { code: 'KR', name: 'South Korea' }, + { code: 'SS', name: 'South Sudan' }, + { code: 'ES', name: 'Spain' }, + { code: 'LK', name: 'Sri Lanka' }, + { code: 'SH', name: 'St Helena' }, + { code: 'KN', name: 'St Kitts and Nevis' }, + { code: 'LC', name: 'St Lucia' }, + { code: 'MF', name: 'St Martin' }, + { code: 'PM', name: 'St Pierre and Miquelon' }, + { code: 'VC', name: 'St Vincent and the Grenadines' }, + { code: 'SD', name: 'Sudan' }, + { code: 'SR', name: 'Suriname' }, + { code: 'SJ', name: 'Svalbard & Jan Mayen' }, + { code: 'SE', name: 'Sweden' }, + { code: 'CH', name: 'Switzerland' }, + { code: 'SY', name: 'Syria' }, + { code: 'TW', name: 'Taiwan' }, + { code: 'TJ', name: 'Tajikistan' }, + { code: 'TZ', name: 'Tanzania' }, + { code: 'TH', name: 'Thailand' }, + { code: 'TL', name: 'Timor-Leste' }, + { code: 'TG', name: 'Togo' }, + { code: 'TK', name: 'Tokelau' }, + { code: 'TO', name: 'Tonga' }, + { code: 'TT', name: 'Trinidad & Tobago' }, + { code: 'TN', name: 'Tunisia' }, + { code: 'TR', name: 'Turkey' }, + { code: 'TM', name: 'Turkmenistan' }, + { code: 'TC', name: 'Turks & Caicos Islands' }, + { code: 'TV', name: 'Tuvalu' }, + { code: 'VI', name: 'US Virgin Islands' }, + { code: 'UG', name: 'Uganda' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'AE', name: 'United Arab Emirates' }, + { code: 'GB', name: 'United Kingdom' }, + { code: 'US', name: 'United States of America' }, + { code: 'UY', name: 'Uruguay' }, + { code: 'UZ', name: 'Uzbekistan' }, + { code: 'VU', name: 'Vanuatu' }, + { code: 'VA', name: 'Vatican City' }, + { code: 'VE', name: 'Venezuela' }, + { code: 'VN', name: 'Vietnam' }, + { code: 'WF', name: 'Wallis & Futuna' }, + { code: 'EH', name: 'Western Sahara' }, + { code: 'YE', name: 'Yemen' }, + { code: 'ZM', name: 'Zambia' }, + { code: 'ZW', name: 'Zimbabwe' }, + { code: 'AX', name: 'Åland Islands' }, +] diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx new file mode 100644 index 00000000000..421c4bb7f3b --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx @@ -0,0 +1,180 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect } from 'react' + +import { useParams } from 'common' +import { + FormActions, + FormPanel, + FormSection, + FormSectionContent, + FormSectionLabel, +} from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation' +import { useCheckPermissions, useStore } from 'hooks' +import { Form, Input, Listbox } from 'ui' +import { COUNTRIES } from './BillingAddress.constants' + +const BillingAddress = () => { + const { ui } = useStore() + const { slug } = useParams() + const { data, isLoading, isSuccess } = useOrganizationCustomerProfileQuery({ slug }) + const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation() + + const formId = 'billing-address-form' + const { city, country, line1, line2, postal_code, state } = data?.address ?? {} + + const canReadBillingAddress = useCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.customer' + ) + const canUpdateBillingAddress = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.customer' + ) + + const initialValues = { city, country, line1, line2, postal_code, state } + + const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => { + if (!slug) return console.error('Slug is required') + + try { + setSubmitting(true) + await updateCustomerProfile({ slug, address: values }) + ui.setNotification({ + category: 'success', + message: 'Successfully updated billing address', + }) + resetForm({ values, initialValues: values }) + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to update billing address: ${error.message}`, + }) + } finally { + setSubmitting(false) + } + } + + return ( + +
+

Billing Address

+

This will be reflected in every invoice

+
+ + } + > + + {!canReadBillingAddress ? ( + + ) : ( +
+ {({ isSubmitting, values, initialValues, handleReset, resetForm }: any) => { + const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (isSuccess && data !== undefined) { + const { city, country, line1, line2, postal_code, state } = data?.address ?? {} + const values = { city, country, line1, line2, postal_code, state } + resetForm({ values, initialValues: values }) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccess]) + + return ( + + +
+ } + > + + + + +
+ + + --- + + {COUNTRIES.map((country) => ( + + {country.name} + + ))} + + +
+
+ + +
+
+
+ + ) + }} + + )} + + + ) +} + +export default BillingAddress diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx new file mode 100644 index 00000000000..64e90b8a3c5 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx @@ -0,0 +1,130 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' + +import { useParams } from 'common' +import { + FormActions, + FormPanel, + FormSection, + FormSectionContent, + FormSectionLabel, +} from 'components/ui/Forms' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { Form, Input } from 'ui' + +const BillingEmail = () => { + const queryClient = useQueryClient() + const { ui } = useStore() + const { slug } = useParams() + const selectedOrganization = useSelectedOrganization() + const { name, billing_email } = selectedOrganization ?? {} + + const formId = 'org-billing-email' + const initialValues = { billing_email: billing_email ?? '' } + + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + const canReadBillingEmail = useCheckPermissions(PermissionAction.READ, 'organizations') + + const onUpdateOrganizationEmail = async (values: any, { setSubmitting, resetForm }: any) => { + if (!canUpdateOrganization) { + return ui.setNotification({ + category: 'error', + message: 'You do not have the required permissions to update this organization', + }) + } + + setSubmitting(true) + const response = await patch(`${API_URL}/organizations/${slug}`, { ...values, name }) + if (response.error) { + ui.setNotification({ + category: 'error', + message: `Failed to update organization: ${response.error.message}`, + }) + } else { + const { billing_email } = response + resetForm({ + values: { billing_email }, + initialValues: { billing_email }, + }) + + invalidateOrganizationsQuery(queryClient) + ui.setNotification({ + category: 'success', + message: 'Successfully saved settings', + }) + } + setSubmitting(false) + } + + return ( + +
+
+

Email Recipient

+

+ All billing correspondence will go to this email +

+
+
+ + } + > + +
+ {({ isSubmitting, handleReset, values, initialValues, resetForm }: any) => { + const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + + // [Alaister] although this "technically" is breaking the rules of React hooks + // it won't error because the hooks are always rendered in the same order + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + const values = { billing_email: billing_email ?? '' } + resetForm({ values, initialValues: values }) + }, [slug]) + + return ( + + +
+ } + > + + + + + + + ) + }} + + + + ) +} + +export default BillingEmail diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx new file mode 100644 index 00000000000..bac99d83061 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx @@ -0,0 +1,37 @@ +import BillingEmail from './BillingEmail' +import CostControl from './CostControl/CostControl' +import Subscription from './Subscription/Subscription' +import CreditBalance from './CreditBalance' +import { LayoutWrapper } from './Scaffold' +import BillingAddress from './BillingAddress/BillingAddress' +import TaxID from './TaxID/TaxID' + +// [Joshen TODO] Last one: payment method details management +// Either as part of the subscription plan component like project, or its own section like the old design + +const BillingSettings = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + + ) +} + +export default BillingSettings diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx new file mode 100644 index 00000000000..90ea7c45e5f --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx @@ -0,0 +1,140 @@ +import Link from 'next/link' + +import { useParams, useTheme } from 'common' +import { BASE_PATH } from 'lib/constants' +import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' +import { Alert, Button, IconExternalLink } from 'ui' +// import SpendCapSidePanel from './SpendCapSidePanel' +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useFlag } from 'hooks' +import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' + +export interface CostControlProps {} + +const CostControl = ({}: CostControlProps) => { + const { slug } = useParams() + const snap = useSubscriptionPageStateSnapshot() + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + const { isDarkMode } = useTheme() + + const { data: subscription, isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) + + const currentPlan = subscription?.plan + const isUsageBillingEnabled = subscription?.usage_billing_enabled ?? false + + const canChangeTier = + !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '') + + return ( + +
+
+

Cost Control

+

+ Control whether to use beyond your plans included quota +

+
+
+

More information

+ +
+
+ + } + > + +
+ {['team', 'enterprise'].includes(currentPlan?.id || '') ? ( + + {currentPlan?.name || ''} plan requires you to have spend cap off at all times. Your + projects will never become unresponsive. Only when your{' '} + + + included usage + + {' '} + is exceeded will you be charged for any additional usage. + + ) : ( +

+ You can control whether your organization is charged for additional usage beyond the{' '} + + + included quota + + {' '} + of your subscription plan. If you need to go beyond the included quota, simply switch + off your spend cap to pay for additional usage. +

+ )} + +
+
+
+ Spend Cap +
+
+
+

Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'}

+

+ {isUsageBillingEnabled ? ( + You will be charged for any usage above the included quota. + ) : ( + + You won't be charged any extra for usage. However, your projects could become + unresponsive or enter read only mode if you exceed the included quota. + + )} +

+ {isUsageBillingEnabled && ( +

+ Your projects will never become unresponsive. Only when your usage reaches the + quota limit will you be charged for any excess usage. +

+ )} + + + +
+
+
+
+
+ ) +} + +export default CostControl diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx new file mode 100644 index 00000000000..cdcd469e729 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx @@ -0,0 +1,48 @@ +import { useParams } from 'common' +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { Badge } from 'ui' + +const CreditBalance = () => { + const { slug } = useParams() + const { data } = useOrganizationCustomerProfileQuery({ slug }) + + const customerBalance = (data?.balance ?? 0) / 100 + const isCredit = customerBalance < 0 + const isDebt = customerBalance > 0 + const balance = + isCredit && customerBalance !== 0 + ? customerBalance.toFixed(2).toString().replace('-', '') + : customerBalance.toFixed(2) + + return ( + +
+
+

Credit balance

+ {isCredit && You have credits available} + {isDebt && Outstanding payments} +
+

+ Charges will be deducted from your balance first +

+
+ + } + > + +
+ {isDebt &&

-

} +

$

+

{balance}

+ {isCredit &&

/credits

} +
+
+
+ ) +} + +export default CreditBalance diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Scaffold.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Scaffold.tsx new file mode 100644 index 00000000000..38ef45cf8f9 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Scaffold.tsx @@ -0,0 +1,5 @@ +import { PropsWithChildren } from 'react' + +export const LayoutWrapper = ({ children }: PropsWithChildren<{}>) => ( +
{children}
+) diff --git a/studio/components/interfaces/Organization/BillingSettings/Subscription/README.md b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/README.md similarity index 100% rename from studio/components/interfaces/Organization/BillingSettings/Subscription/README.md rename to studio/components/interfaces/Organization/BillingSettingsV2/Subscription/README.md diff --git a/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx similarity index 81% rename from studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx rename to studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx index c44cb8fd99f..cc39cf513b2 100644 --- a/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx @@ -3,12 +3,12 @@ import Link from 'next/link' import { useParams } from 'common' import AlertError from 'components/ui/AlertError' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' +import SparkBar from 'components/ui/SparkBar' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useFlag } from 'hooks' import { Alert, Button, IconExternalLink } from 'ui' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' -import SparkBar from 'components/ui/SparkBar' +import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' const Subscription = () => { const { slug } = useParams() @@ -32,37 +32,31 @@ const Subscription = () => { const canChangeTier = !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id ?? '') - console.log({ subscription }) - return ( -
-
-
-

Subscription plan

-
-

More information

-
- - -
-

Pricing

- -
-
- + +
+

Subscription Plan

+
+

More information

+
-
-
-
- {isLoading && ( -
- - - -
- )} - + + } + > + {isError && } {isSuccess && ( @@ -166,8 +160,8 @@ const Subscription = () => { />
)} -
-
+ + ) } diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.constants.ts b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.constants.ts new file mode 100644 index 00000000000..9595d935efb --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.constants.ts @@ -0,0 +1,459 @@ +// List of available Tax IDs as reflected in Stripe's web portal +// This was manually ported over so there may be a chance of mistakes +// Last updated as of 29th March 2022. +// The code may not necessarily match with the name (ref SE_VAT) +// https://stripe.com/docs/api/customer_tax_ids/create +export interface TaxId { + name: string + code: string + country: string + placeholder: string + vatPrefix?: string +} + +export const TAX_IDS: TaxId[] = [ + { + name: 'AE TRN', + code: 'ae_trn', + country: 'United Arab Emirates', + placeholder: '123456789012345', + }, + { + name: 'AT VAT', + code: 'eu_vat', + country: 'Austria', + placeholder: 'ATU12345678', + vatPrefix: 'ATU', + }, + { + name: 'AU ABN', + code: 'au_abn', + country: 'Australia', + placeholder: '12345678912', + }, + { + name: 'AU ARN', + code: 'au_arn', + country: 'Australia', + placeholder: '123456789123', + }, + { + name: 'BE VAT', + code: 'eu_vat', + country: 'Belgium', + placeholder: 'BE0123456789', + vatPrefix: 'BE', + }, + { + name: 'BG VAT', + code: 'eu_vat', + country: 'Bulgaria', + placeholder: 'BG0123456789', + vatPrefix: 'BG', + }, + { + name: 'BR CNPJ', + code: 'br_cnpj', + country: 'Brazil', + placeholder: '01.234.456/5432-10', + }, + { + name: 'BR CPF', + code: 'br_cpf', + country: 'Brazil', + placeholder: '123.456.789-87', + }, + { + name: 'CA BN', + code: 'ca_bn', + country: 'Canada', + placeholder: '123456789', + }, + { + name: 'CA GST/HST', + code: 'ca_gst_hst', + country: 'Canada', + placeholder: '123456789RT0002', + }, + { + name: 'CA PST-BC', + code: 'ca_pst_bc', + country: 'Canada', + placeholder: 'PST-1234-5678', + }, + { + name: 'CA PST-MB', + code: 'ca_pst_mb', + country: 'Canada', + placeholder: '123456-7', + }, + { + name: 'CA PST-SK', + code: 'ca_pst_mb', + country: 'Canada', + placeholder: '1234567', + }, + { + name: 'CA QST', + code: 'ca_qst', + country: 'Canada', + placeholder: '1234567890TQ1234', + }, + { + name: 'CH VAT', + code: 'ch_vat', + country: 'Switzerland', + placeholder: 'CHE-123.456.789 MWST', + vatPrefix: 'CHE', + }, + { + name: 'CL TIN', + code: 'cl_tin', + country: 'Chile', + placeholder: '12.345.678-K', + }, + { + name: 'CY VAT', + code: 'eu_vat', + country: 'Cyprus', + placeholder: 'CY12345678Z', + vatPrefix: 'CY', + }, + { + name: 'CZ VAT', + code: 'eu_vat', + country: 'Czech Republic', + placeholder: 'CZ1234567890', + vatPrefix: 'CZ', + }, + { + name: 'DE VAT', + code: 'eu_vat', + country: 'Germany', + placeholder: 'DE123456789', + vatPrefix: 'DE', + }, + { + name: 'DK VAT', + code: 'eu_vat', + country: 'Denmark', + placeholder: 'DK12345678', + vatPrefix: 'DK', + }, + { + name: 'EE VAT', + code: 'eu_vat', + country: 'Estonia', + placeholder: 'EE123456789', + vatPrefix: 'EE', + }, + { + name: 'ES CIF', + code: 'es_cif', + country: 'Spain', + placeholder: 'A12345678', + }, + { + name: 'ES VAT', + code: 'eu_vat', + country: 'Spain', + placeholder: 'ESX1234567Z', + vatPrefix: 'ES', + }, + { + name: 'FI VAT', + code: 'eu_vat', + country: 'Finland', + placeholder: 'FI12345678', + vatPrefix: 'FI', + }, + { + name: 'FR VAT', + code: 'eu_vat', + country: 'France', + placeholder: 'FRAB123456789', + vatPrefix: 'FR', + }, + { + name: 'GB VAT', + code: 'eu_vat', + country: 'United Kingdom', + placeholder: 'GB123456789', + vatPrefix: 'GB', + }, + { + name: 'GE VAT', + code: 'ge_vat', + country: 'Georgia', + placeholder: '123456789', + }, + { + name: 'EL VAT', + code: 'eu_vat', + country: 'Greece', + placeholder: 'EL123456789', + vatPrefix: 'EL', + }, + { + name: 'HK BR', + code: 'hk_br', + country: 'Hong Kong SAR China', + placeholder: '12345678', + }, + { + name: 'HR VAT', + code: 'eu_vat', + country: 'Croatia', + placeholder: 'HR12345678912', + vatPrefix: 'HR', + }, + { + name: 'HU VAT', + code: 'eu_vat', + country: 'Hungary', + placeholder: 'HU12345678912', + vatPrefix: 'HU', + }, + { + name: 'ID NPWP', + code: 'id_npwp', + country: 'Indonesia', + placeholder: '12.345.678.9-012.345', + }, + { + name: 'IE VAT', + code: 'eu_vat', + country: 'Ireland', + placeholder: 'IE1234567AB', + vatPrefix: 'IE', + }, + { + name: 'IL VAT', + code: 'il_vat', + country: 'Israel', + placeholder: '000012345', + }, + { + name: 'IN GST', + code: 'in_gst', + country: 'India', + placeholder: '12ABCDE3456FGZH', + }, + { + name: 'IS VAT', + code: 'is_vat', + country: 'Iceland', + placeholder: '123456', + }, + { + name: 'IT VAT', + code: 'eu_vat', + country: 'Italy', + placeholder: 'IT12345678912', + vatPrefix: 'IT', + }, + { + name: 'JP CN', + code: 'jp_cn', + country: 'Japan', + placeholder: '1234567891234', + }, + { + name: 'JP RN', + code: 'jp_rn', + country: 'Japan', + placeholder: '12345', + }, + { + name: 'KR BRN', + code: 'kr_brn', + country: 'Korea', + placeholder: '123-45-67890', + }, + { + name: 'LI UID', + code: 'li_uid', + country: 'Liechtenstein', + placeholder: 'CHE123456789', + }, + { + name: 'LT VAT', + code: 'eu_vat', + country: 'Lithuania', + placeholder: 'LT123456789123', + vatPrefix: 'LT', + }, + { + name: 'LU VAT', + code: 'eu_vat', + country: 'Luxembourg', + placeholder: 'LU12345678', + vatPrefix: 'LU', + }, + { + name: 'LV VAT', + code: 'eu_vat', + country: 'Latvia', + placeholder: 'LV12345678912', + vatPrefix: 'LV', + }, + { + name: 'MT VAT', + code: 'eu_vat', + country: 'Malta', + placeholder: 'MT12345678', + vatPrefix: 'MT', + }, + { + name: 'MX RFC', + code: 'mx_rfc', + country: 'Mexico', + placeholder: 'ABC010203AB9', + }, + { + name: 'MY FRP', + code: 'my_frp', + country: 'Malaysia', + placeholder: '12345678', + }, + { + name: 'MY ITN', + code: 'my_itn', + country: 'Malaysia', + placeholder: 'C 1234567890', + }, + { + name: 'MY SST', + code: 'my_sst', + country: 'Malaysia', + placeholder: 'A12-3456-78912345', + }, + { + name: 'NL VAT', + code: 'eu_vat', + country: 'Netherlands', + placeholder: 'NL123456789B12', + vatPrefix: 'NL', + }, + { + name: 'NO VAT', + code: 'no_vat', + country: 'Norway', + placeholder: '123456789MVA', + }, + { + name: 'NZ GST', + code: 'nz_gst', + country: 'New Zealand', + placeholder: '123456789', + }, + { + name: 'PL VAT', + code: 'eu_vat', + country: 'Poland', + placeholder: 'PL1234567890', + vatPrefix: 'PL', + }, + { + name: 'PT VAT', + code: 'eu_vat', + country: 'Portugal', + placeholder: 'PT123456789', + vatPrefix: 'PT', + }, + { + name: 'RO VAT', + code: 'eu_vat', + country: 'Romania', + placeholder: 'RO1234567891', + vatPrefix: 'RO', + }, + { + name: 'RU INN', + code: 'ru_inn', + country: 'Russia', + placeholder: '1234567891', + }, + { + name: 'RU KPP', + code: 'ru_kpp', + country: 'Russia', + placeholder: '123456789', + }, + { + name: 'SA VAT', + code: 'sa_vat', + country: 'Saudi Arabia', + placeholder: '123456789012345', + }, + { + name: 'SE VAT', + code: 'eu_vat', + country: 'Sweden', + placeholder: 'SE123456789123', + vatPrefix: 'SE', + }, + { + name: 'SG GST', + code: 'sg_gst', + country: 'Singapore', + placeholder: 'M12345678X', + }, + { + name: 'SG UEN', + code: 'sg_uen', + country: 'Singapore', + placeholder: '123456789F', + }, + { + name: 'SI VAT', + code: 'eu_vat', + country: 'Slovenia', + placeholder: 'SI12345678', + vatPrefix: 'SI', + }, + { + name: 'SK VAT', + code: 'eu_vat', + country: 'Slovakia', + placeholder: 'SK1234567891', + vatPrefix: 'SK', + }, + { + name: 'TH VAT', + code: 'th_vat', + country: 'Thailand', + placeholder: '1234567891234', + }, + { + name: 'TW VAT', + code: 'tw_vat', + country: 'Taiwan', + placeholder: '12345678', + }, + { + name: 'UA VAT', + code: 'ua_vat', + country: 'Ukraine', + placeholder: '123456789', + }, + { + name: 'US EIN', + code: 'us_ein', + country: 'United States', + placeholder: '12-3456789', + }, + { + name: 'XI VAT', + code: 'eu_vat', + country: 'United Kingdom (Northern Ireland)', + placeholder: 'XI123456789', + vatPrefix: 'XI', + }, + { + name: 'ZA VAT', + code: 'za_vat', + country: 'South Africa', + placeholder: '4123456789', + }, +] diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx new file mode 100644 index 00000000000..28b3957a9fc --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState } from 'react' +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { + FormActions, + FormPanel, + FormSection, + FormSectionContent, + FormSectionLabel, +} from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, useStore } from 'hooks' +import { Button, Form, IconPlus, IconX, Input, Listbox } from 'ui' +import { TAX_IDS } from './TaxID.constants' +import { TaxId, useOrganizationTaxIDsQuery } from 'data/organizations/organization-tax-ids-query' +import { useParams } from 'common' +import { uuidv4 } from 'lib/helpers' +import { isEqual } from 'lodash' +import { + TaxIdValue, + useOrganizationTaxIDsUpdateMutation, +} from 'data/organizations/organization-tax-ids-update-mutation' +import { sanitizeTaxID } from './TaxID.utilts' + +const TaxID = () => { + const { ui } = useStore() + const { slug } = useParams() + const { data: taxIds, isLoading, isSuccess, isError } = useOrganizationTaxIDsQuery({ slug }) + const { mutateAsync: updateTaxIDs } = useOrganizationTaxIDsUpdateMutation() + + const [errors, setErrors] = useState([]) + const [taxIdValues, setTaxIdValues] = useState([]) + + const formattedTaxIds = + taxIds?.map((taxId: TaxId) => { + return { + id: taxId.id, + type: taxId.type, + value: taxId.value, + name: + taxId.type === 'eu_vat' + ? `${taxId.country} VAT` + : TAX_IDS.find((option) => option.code === taxId.type)?.name ?? '', + } + }) ?? [] + + const formId = 'tax-id-form' + const hasChanges = !isEqual(taxIdValues, formattedTaxIds) + const canReadTaxIds = useCheckPermissions(PermissionAction.BILLING_READ, 'stripe.tax_ids') + const canUpdateTaxIds = useCheckPermissions(PermissionAction.BILLING_WRITE, 'stripe.tax_ids') + + useEffect(() => { + if (isSuccess) { + if (taxIdValues.length === 0) { + setTaxIdValues(formattedTaxIds) + } else { + const erroredTaxIds = taxIdValues.filter((taxId: any) => errors.includes(taxId.id)) + setTaxIdValues(formattedTaxIds.concat(erroredTaxIds)) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccess]) + + const onUpdateTaxId = (id: string, key: string, value: string) => { + const updatedTaxIds = taxIdValues.map((taxId: any) => { + if (taxId.id === id) { + if (key === 'name') { + const selectedTaxIdOption = TAX_IDS.find((option) => option.name === value) + return { ...taxId, [key]: value, type: selectedTaxIdOption?.code } + } else { + return { ...taxId, [key]: value } + } + } + return taxId + }) + setTaxIdValues(updatedTaxIds) + } + + const onAddNewTaxId = () => { + const newTaxId = { id: uuidv4(), type: TAX_IDS[0].code, value: '', name: TAX_IDS[0].name } + const updatedTaxIds = taxIdValues.concat([newTaxId]) + setTaxIdValues(updatedTaxIds) + } + + const onRemoveTaxId = (id: string) => { + const updatedTaxIds = taxIdValues.filter((taxId) => taxId.id !== id) + setTaxIdValues(updatedTaxIds) + } + + const onResetTaxIds = () => { + setTaxIdValues(formattedTaxIds) + } + + const onSaveTaxIds = async (values: any, { setSubmitting }: any) => { + if (!slug) return console.error('Slug is required') + if (taxIds === undefined) return console.error('Tax IDs are required') + + try { + setSubmitting(true) + const newIds = taxIdValues.map((x) => sanitizeTaxID(x)) + const { errors } = await updateTaxIDs({ slug, existingIds: taxIds, newIds }) + setErrors(errors?.map((taxId) => taxId.id) ?? []) + + if (errors !== undefined && errors.length > 0) { + errors.forEach((taxId: any) => { + ui.setNotification({ category: 'error', message: taxId.result.error.message }) + }) + } else { + ui.setNotification({ category: 'success', message: 'Successfully updated tax IDs' }) + } + } catch (error: any) { + ui.setNotification({ category: 'error', message: `Failed to save tax IDs: ${error.message}` }) + } finally { + setSubmitting(false) + } + } + + return ( + +
+
+

Tax ID

+

+ If you would like to include specific tax ID(s) to your invoices. +

+

+ Make sure the tax ID looks exactly like the placeholder text. +

+
+
+ + } + > + + {!canReadTaxIds ? ( + + ) : ( +
+ {({ isSubmitting }: { isSubmitting: boolean }) => ( + + onResetTaxIds()} + helper={ + !canUpdateTaxIds + ? "You need additional permissions to manage this organization's tax IDs" + : undefined + } + /> +
+ } + > + + + {taxIdValues.length >= 1 ? ( +
+ {taxIdValues.map((taxId, idx: number) => { + const selectedTaxId = TAX_IDS.find((option) => option.code === taxId.type) + return ( +
+ onUpdateTaxId(taxId.id, 'name', e)} + disabled={!canUpdateTaxIds} + className="w-[200px]" + > + {TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map( + (option) => ( + + {option.country} - {option.name} + + ) + )} + + + onUpdateTaxId(taxId.id, 'value', e.target.value) + } + disabled={!canUpdateTaxIds} + /> + {canUpdateTaxIds && ( +
+ ) + })} +
+ ) : ( +
+

+ No tax IDs +

+
+ )} + {canUpdateTaxIds && ( +
onAddNewTaxId()} + > + +

Add another ID

+
+ )} +
+
+ + )} + + )} + + + ) +} + +export default TaxID diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.utilts.ts b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.utilts.ts new file mode 100644 index 00000000000..e44856a58be --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.utilts.ts @@ -0,0 +1,24 @@ +import { TaxId } from 'data/organizations/organization-tax-ids-query' +import { TAX_IDS } from './TaxID.constants' +import { TaxIdValue } from 'data/organizations/organization-tax-ids-update-mutation' + +/** + * Sanitize EU VAT ids so they get prepended with the country code + * + * Ex: Belgium's VAT ID should be BE0123456789 not 0123456789 + * Only EU VAT ids are affected + */ +export const sanitizeTaxID = (taxId: TaxIdValue) => { + let newID = taxId + + const selectedTaxId = TAX_IDS.find((option) => option.code === taxId.type) + + const vatIdPrefix = selectedTaxId?.vatPrefix + + // if the value doesn't start with the prefix, prepend them + if (vatIdPrefix && !taxId.value.startsWith(vatIdPrefix)) { + newID.value = `${vatIdPrefix}${taxId.value}` + } + + return newID +} diff --git a/studio/components/interfaces/Organization/index.ts b/studio/components/interfaces/Organization/index.ts index c041402fdf2..2f0f370861b 100644 --- a/studio/components/interfaces/Organization/index.ts +++ b/studio/components/interfaces/Organization/index.ts @@ -1,12 +1,13 @@ import GeneralSettings from './GeneralSettings/GeneralSettings' import TeamSettings from './TeamSettings/TeamSettings' import BillingSettings from './BillingSettings/BillingSettings' -import BillingSettingsV2 from './BillingSettings/BillingSettingsV2' import InvoicesSettings from './InvoicesSettings/InvoicesSettings' import NewOrgForm from './NewOrg/NewOrgForm' import Usage from './Usage/Usage' import OAuthApps from './OAuthApps/OAuthApps' +import BillingSettingsV2 from './BillingSettingsV2/BillingSettings' + export { GeneralSettings, TeamSettings, diff --git a/studio/components/ui/Forms/FormSection.tsx b/studio/components/ui/Forms/FormSection.tsx index a94b81d44d4..5baa7ca7f56 100644 --- a/studio/components/ui/Forms/FormSection.tsx +++ b/studio/components/ui/Forms/FormSection.tsx @@ -2,12 +2,14 @@ import { Children } from 'react' const FormSection = ({ children, + id, header, disabled, visible = true, className, }: { children: React.ReactNode + id?: string header?: React.ReactNode disabled?: boolean visible?: boolean @@ -21,7 +23,7 @@ const FormSection = ({ ] return ( -
+
{header} {children}
diff --git a/studio/data/organizations/keys.ts b/studio/data/organizations/keys.ts index a7b2d230164..ecac86e4ae7 100644 --- a/studio/data/organizations/keys.ts +++ b/studio/data/organizations/keys.ts @@ -10,4 +10,5 @@ export const organizationKeys = { migrateBilling: (slug: string | undefined) => ['organizations', slug, 'migrate-billing'] as const, migrateBillingPreview: (slug: string | undefined) => ['organizations', slug, 'migrate-billing', 'preview'] as const, + taxIds: (slug: string | undefined) => ['organizations', slug, 'tax-ids'] as const, } diff --git a/studio/data/organizations/organization-customer-profile-query.ts b/studio/data/organizations/organization-customer-profile-query.ts index 31c6e4d55b3..de495773339 100644 --- a/studio/data/organizations/organization-customer-profile-query.ts +++ b/studio/data/organizations/organization-customer-profile-query.ts @@ -9,7 +9,14 @@ export type OrganizationCustomerProfileVariables = { } export type OrganizationCustomerProfileResponse = { - address: string | null + address: { + city: string | null + country: string | null + line1: string | null + line2: string | null + postal_code: string | null + state: string | null + } | null balance: number email: string id: string diff --git a/studio/data/organizations/organization-customer-profile-update-mutation.ts b/studio/data/organizations/organization-customer-profile-update-mutation.ts new file mode 100644 index 00000000000..f038e76ab45 --- /dev/null +++ b/studio/data/organizations/organization-customer-profile-update-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationCustomerProfileUpdateVariables = { + slug: string + address?: { + city: string | null + country: string | null + line1: string | null + line2: string | null + postal_code: string | null + state: string | null + } | null +} + +export async function updateOrganizationCustomerProfile({ + slug, + address, +}: OrganizationCustomerProfileUpdateVariables) { + if (!slug) return console.error('Slug is required') + + const response = await patch(`${API_URL}/organizations/${slug}/customer`, { address }) + if (response.error) throw response.error + return response +} + +type OrganizationCustomerProfileUpdateData = Awaited< + ReturnType +> + +export const useOrganizationCustomerProfileUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + OrganizationCustomerProfileUpdateData, + unknown, + OrganizationCustomerProfileUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OrganizationCustomerProfileUpdateData, + unknown, + OrganizationCustomerProfileUpdateVariables + >((vars) => updateOrganizationCustomerProfile(vars), { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(organizationKeys.customerProfile(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/organizations/organization-tax-ids-query.ts b/studio/data/organizations/organization-tax-ids-query.ts new file mode 100644 index 00000000000..059840795fd --- /dev/null +++ b/studio/data/organizations/organization-tax-ids-query.ts @@ -0,0 +1,68 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { organizationKeys } from './keys' + +export type OrganizationTaxIDsVariables = { + slug?: string +} + +export type TaxId = { + country: string + created: number + customer: string + id: string + livemode: boolean + object: 'tax_id' + type: string + value: string + verification: { + status: string + verified_address: string | null + verified_name: string | null + } +} + +export async function getOrganizationTaxIDs( + { slug }: OrganizationTaxIDsVariables, + signal?: AbortSignal +) { + if (!slug) throw new Error('slug is required') + + const data = await get(`${API_URL}/organizations/${slug}/tax-ids`, { signal }) + if (data.error) throw data.error + + return data.data as TaxId[] +} + +export type OrganizationTaxIDsData = Awaited> +export type OrganizationTaxIDsError = unknown + +export const useOrganizationTaxIDsQuery = ( + { slug }: OrganizationTaxIDsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + organizationKeys.taxIds(slug), + ({ signal }) => getOrganizationTaxIDs({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useOrganizationTaxIDsPrefetch = ({ slug }: OrganizationTaxIDsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(organizationKeys.taxIds(slug), ({ signal }) => + getOrganizationTaxIDs({ slug }, signal) + ) + } + }, [slug]) +} diff --git a/studio/data/organizations/organization-tax-ids-update-mutation.ts b/studio/data/organizations/organization-tax-ids-update-mutation.ts new file mode 100644 index 00000000000..36ea34fd23d --- /dev/null +++ b/studio/data/organizations/organization-tax-ids-update-mutation.ts @@ -0,0 +1,82 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_, post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' +import { TaxId } from './organization-tax-ids-query' + +export interface TaxIdValue { + id: string + type: string + value: string + name: string +} + +export type OrganizationTaxIDsUpdateVariables = { + slug: string + existingIds: TaxId[] + newIds: TaxIdValue[] +} + +export async function updateOrganizationTaxIDs({ + slug, + existingIds, + newIds, +}: OrganizationTaxIDsUpdateVariables) { + if (!slug) { + console.error('Slug is required') + return {} + } + + // To make things simple we delete all existing ones and create new ones from this session + if (existingIds.length > 0) { + await Promise.all( + existingIds.map(async (taxId) => { + return await delete_(`${API_URL}/organizations/${slug}/tax-ids`, { id: taxId.id }) + }) + ) + } + + const createdIds = await Promise.all( + newIds.map(async (taxId) => { + const result = await post(`${API_URL}/organizations/${slug}/tax-ids`, { + type: taxId.type, + value: taxId.value, + }) + return { id: taxId.id, result } + }) + ) + + const taxIdsWithErrors = createdIds.filter((taxId) => { + if (taxId.result.error) return taxId + }) + + const idsCreated = createdIds + .filter((taxId: any) => !taxId.result.error) + .map((taxId: any) => taxId.result) + + return { created: idsCreated, errors: taxIdsWithErrors } +} + +type OrganizationTaxIDsUpdateData = Awaited> + +export const useOrganizationTaxIDsUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateOrganizationTaxIDs(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(organizationKeys.customerProfile(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} From cc32db3281584e196201973f2bdb5e7eed4cbb51 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 3 Jul 2023 12:00:18 +0800 Subject: [PATCH 07/21] Add payment methods to org billing v2 --- .../BillingSettingsV2/BillingSettings.tsx | 4 + .../DeletePaymentMethodModal.tsx | 80 +++++++ .../PaymentMethods/PaymentMethods.tsx | 180 +++++++++++++++ .../Subscription/PaymentMethodSelection.tsx | 181 +++++++++++++++ .../Subscription/Subscription.tsx | 5 + .../SubscriptionPaymentMethod.tsx | 216 ++++++++++++++++++ ...nization-payment-method-delete-mutation.ts | 48 ++++ .../organization-payment-methods-query.ts | 6 +- .../org-subscription-update-mutation.ts | 62 +++++ 9 files changed, 779 insertions(+), 3 deletions(-) create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/DeletePaymentMethodModal.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PaymentMethodSelection.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/Subscription/SubscriptionPaymentMethod.tsx create mode 100644 studio/data/organizations/organization-payment-method-delete-mutation.ts create mode 100644 studio/data/subscriptions/org-subscription-update-mutation.ts diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx index bac99d83061..e42a8eceed6 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx @@ -5,6 +5,7 @@ import CreditBalance from './CreditBalance' import { LayoutWrapper } from './Scaffold' import BillingAddress from './BillingAddress/BillingAddress' import TaxID from './TaxID/TaxID' +import PaymentMethods from './PaymentMethods/PaymentMethods' // [Joshen TODO] Last one: payment method details management // Either as part of the subscription plan component like project, or its own section like the old design @@ -21,6 +22,9 @@ const BillingSettings = () => { + + + diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/DeletePaymentMethodModal.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/DeletePaymentMethodModal.tsx new file mode 100644 index 00000000000..66fb26260b5 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/DeletePaymentMethodModal.tsx @@ -0,0 +1,80 @@ +import { useParams } from 'common' +import { useOrganizationPaymentMethodDeleteMutation } from 'data/organizations/organization-payment-method-delete-mutation' +import { OrganizationPaymentMethod } from 'data/organizations/organization-payment-methods-query' +import { useStore } from 'hooks' +import { Alert, Button, Modal } from 'ui' + +export interface DeletePaymentMethodModalProps { + selectedPaymentMethod?: OrganizationPaymentMethod + onClose: () => void +} + +const DeletePaymentMethodModal = ({ + selectedPaymentMethod, + onClose, +}: DeletePaymentMethodModalProps) => { + const { ui } = useStore() + const { slug } = useParams() + const { mutateAsync: deletePayment, isLoading: isDeleting } = + useOrganizationPaymentMethodDeleteMutation() + + const onConfirmDelete = async () => { + if (!slug) return console.error('Slug is required') + if (!selectedPaymentMethod) return console.error('Card ID is required') + + try { + await deletePayment({ slug, cardId: selectedPaymentMethod.id }) + ui.setNotification({ + category: 'success', + message: `Successfully removed payment method ending with ${selectedPaymentMethod.card.last4}`, + }) + onClose() + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to delete payment method: ${error.message}`, + }) + } + } + + return ( + onClose()} + customFooter={ +
+ + +
+ } + > +
+ + +

+ Any subscription currently charging this payment method will start charging your + default payment method. +

+
+
+
+
+ ) +} + +export default DeletePaymentMethodModal diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx new file mode 100644 index 00000000000..7e0dc88e4f2 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx @@ -0,0 +1,180 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' + +import { useParams } from 'common' +import { AddNewPaymentMethodModal } from 'components/interfaces/Billing' +import { FormPanel, FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' +import { organizationKeys } from 'data/organizations/keys' +import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' +import { useCheckPermissions, useStore } from 'hooks' +import { getURL } from 'lib/helpers' +import { Badge, Button, Dropdown, IconCreditCard, IconMoreHorizontal, IconPlus, Input } from 'ui' +import { BASE_PATH } from 'lib/constants' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import DeletePaymentMethodModal from './DeletePaymentMethodModal' + +// [TODO] Implement change credit card for org subscription logic + +const PaymentMethods = () => { + const { ui } = useStore() + const { slug } = useParams() + const queryClient = useQueryClient() + const [selectedMethodForDefault, setSelectedMethodForDefault] = useState() + const [selectedMethodToDelete, setSelectedMethodToDelete] = useState() + const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false) + + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { + data: paymentMethods, + isLoading, + isError, + isSuccess, + } = useOrganizationPaymentMethodsQuery({ slug }) + + const canReadPaymentMethods = useCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.payment_methods' + ) + const canUpdatePaymentMethods = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.payment_methods' + ) + + return ( + <> + +
+
+

Payment methods

+

+ This copy might need to change, when adding a new payment method, either remove + the old one or go to your projects' subscription to explicitly update the payment + method. +

+
+
+ + } + > + + {!canReadPaymentMethods ? ( + + ) : ( + + {!canUpdatePaymentMethods ? ( +

+ You need additional permissions to manage payment methods +

+ ) : ( +
+ )} + +
+ } + > + + + {(paymentMethods?.length ?? 0) === 0 ? ( +
+ +

No payment methods

+
+ ) : ( +
+ {paymentMethods?.map((paymentMethod) => { + const activePaymentMethod = + subscription?.payment_method_id === paymentMethod.id + return ( +
+
+ Credit card brand +

+ **** **** **** {paymentMethod.card.last4} +

+

+ Expires: {paymentMethod.card.exp_month}/ + {paymentMethod.card.exp_year} +

+ {activePaymentMethod && Active} +
+ {canUpdatePaymentMethods ? ( + setSelectedMethodForDefault(paymentMethod)} + > + Make default + , + setSelectedMethodToDelete(paymentMethod)} + > + Delete + , + ]} + > +
+ ) + })} +
+ )} +
+
+
+ )} +
+
+ + setShowAddPaymentMethodModal(false)} + onConfirm={async () => { + setShowAddPaymentMethodModal(false) + ui.setNotification({ + category: 'success', + message: 'Successfully added new payment method', + }) + await queryClient.invalidateQueries(organizationKeys.paymentMethods(slug)) + }} + /> + + setSelectedMethodToDelete(undefined)} + /> + + ) +} + +export default PaymentMethods diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PaymentMethodSelection.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PaymentMethodSelection.tsx new file mode 100644 index 00000000000..5c955148e91 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PaymentMethodSelection.tsx @@ -0,0 +1,181 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { getURL } from 'lib/helpers' +import { Button, IconAlertCircle, IconCreditCard, IconLoader, IconPlus, Listbox } from 'ui' +import { AddNewPaymentMethodModal } from 'components/interfaces/Billing' + +export interface PaymentMethodSelectionProps { + selectedPaymentMethod?: string + onSelectPaymentMethod: (id: string) => void +} + +const PaymentMethodSelection = ({ + selectedPaymentMethod, + onSelectPaymentMethod, +}: PaymentMethodSelectionProps) => { + const { ui } = useStore() + const { ref: projectRef } = useParams() + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug + const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false) + + const { + data, + isLoading, + isSuccess: loadedPaymentMethods, + refetch: refetchPaymentMethods, + } = useOrganizationPaymentMethodsQuery({ slug }) + const paymentMethods = useMemo(() => data ?? [], [data]) + + const { data: customerProfile, isSuccess: loadedCustomerProfile } = + useOrganizationCustomerProfileQuery({ slug }) + + const canUpdatePaymentMethods = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.payment_methods' + ) + + useEffect(() => { + if (loadedPaymentMethods && loadedCustomerProfile && paymentMethods.length > 0) { + const selectedPaymentMethodExists = paymentMethods.some( + (it) => it.id === selectedPaymentMethod + ) + + if (!selectedPaymentMethod || !selectedPaymentMethodExists) { + const defaultPaymentMethod = paymentMethods.find( + (method) => method.id === customerProfile.invoice_settings.default_payment_method + ) + if (defaultPaymentMethod !== undefined) { + onSelectPaymentMethod(defaultPaymentMethod.id) + } else { + onSelectPaymentMethod(paymentMethods[0].id) + } + } + } + }, [ + loadedPaymentMethods, + loadedCustomerProfile, + selectedPaymentMethod, + customerProfile, + paymentMethods, + onSelectPaymentMethod, + ]) + + return ( + <> +
+

Select payment method

+ {isLoading ? ( +
+ +

Retrieving payment methods

+
+ ) : paymentMethods.length === 0 ? ( +
+
+ +

No saved payment methods

+
+ + + + + + {!canUpdatePaymentMethods && ( + + + +
+ + You need additional permissions to add new payment methods to this + organization + +
+
+
+ )} +
+
+ ) : ( + + {paymentMethods.map((method: any) => { + const label = `•••• •••• •••• ${method.card.last4}` + return ( + { + return ( + Credit Card Brand + ) + }} + > +
{label}
+
+ ) + })} +
setShowAddNewPaymentMethodModal(true)} + > + +

+ Add new payment method +

+
+
+ )} +
+ + setShowAddNewPaymentMethodModal(false)} + onConfirm={async () => { + setShowAddNewPaymentMethodModal(false) + ui.setNotification({ + category: 'success', + message: 'Successfully added new payment method', + }) + const { data } = await refetchPaymentMethods() + if (data?.length) { + // Preselect the card that was just added + const mostRecentPaymentMethod = data.reduce( + (prev, current) => (prev.created > current.created ? prev : current), + data[0] + ) + onSelectPaymentMethod(mostRecentPaymentMethod.id) + } + }} + /> + + ) +} + +export default PaymentMethodSelection diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx index cc39cf513b2..032612c1f29 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx @@ -9,6 +9,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { useFlag } from 'hooks' import { Alert, Button, IconExternalLink } from 'ui' import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' +import SubscriptionPaymentMethod from './SubscriptionPaymentMethod' const Subscription = () => { const { slug } = useParams() @@ -158,6 +159,10 @@ const Subscription = () => { labelBottomClass="!text-scale-1000 pb-1" labelTop={`${daysToCycleEnd} Days left`} /> + + {subscription !== undefined && ( + + )}
)} diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/SubscriptionPaymentMethod.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/SubscriptionPaymentMethod.tsx new file mode 100644 index 00000000000..ef1d67e8d25 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/SubscriptionPaymentMethod.tsx @@ -0,0 +1,216 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useState } from 'react' + +import { useParams } from 'common' +import Panel from 'components/ui/Panel' +import { + SubscriptionTier, + updateSubscriptionTier, +} from 'data/subscriptions/project-subscription-update-mutation' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import Link from 'next/link' +import { Button, IconCreditCard, Modal } from 'ui' +import PaymentMethodSelection from './PaymentMethodSelection' +import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' + +export interface SubscriptionTierProps { + subscription: ProjectSubscriptionResponse +} + +const SubscriptionPaymentMethod = ({ subscription }: SubscriptionTierProps) => { + const { ui } = useStore() + const { slug } = useParams() + + const selectedOrganization = useSelectedOrganization() + const currentOrgSlug = selectedOrganization?.slug + + const { mutateAsync: updateOrgSubscription } = useOrgSubscriptionUpdateMutation() + + const canUpdatePaymentMethod = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.subscriptions' + ) + + const [isSubmitting, setIsSubmitting] = useState(false) + const [showPaymentEditModal, setShowPaymentEditModal] = useState(false) + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState( + subscription.payment_method_id || '' + ) + + const onConfirmPaymentMethod = async () => { + setIsSubmitting(true) + + if (selectedPaymentMethod) { + if (!slug) return console.error('Slug is required') + if (!selectedPaymentMethod) { + return ui.setNotification({ category: 'error', message: 'Please select a payment method' }) + } + + const selectedTier = + subscription.plan.id === 'pro' && subscription.usage_billing_enabled + ? 'tier_payg' + : `tier_${subscription.plan.id}` + + try { + setIsSubmitting(true) + await updateOrgSubscription({ + slug, + tier: selectedTier as SubscriptionTier, + paymentMethod: selectedPaymentMethod, + }) + setShowPaymentEditModal(false) + ui.setNotification({ category: 'success', message: `Successfully updated payment method!` }) + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Unable to update subscription: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + setIsSubmitting(false) + } + + if (subscription.payment_method_type === 'none') return null + + return ( + <> + + + {subscription.payment_method_type === 'card' && ( + <> +
+ {subscription.payment_method_card_details?.brand ? ( + Credit card brand + ) : ( + + )} + + **** **** ****{' '} + {subscription?.payment_method_card_details?.last_4_digits || '****'} + +
+
+ + Expires {subscription?.payment_method_card_details?.expiry_month || '-'}/ + {subscription?.payment_method_card_details?.expiry_year?.toString()?.slice(-2) || + '-'} + +
+ + )} + + {subscription?.payment_method_type === 'invoice' && ( + <> +
+
+

Payment via invoice

+

+ You get a monthly invoice and payment link via email. +

+
+
+ + )} + + + +
+ +
+
+ {subscription?.payment_method_type !== 'card' ? ( + + + +
+ + Please email support@supbase.io to change your payment method. + +
+
+
+ ) : !canUpdatePaymentMethod ? ( + + + +
+ + You do not have permission to change the payment method + +
+
+
+ ) : null} +
+
+
+ + setShowPaymentEditModal(false)} + onConfirm={onConfirmPaymentMethod} + overlayClassName="pointer-events-none" + header={`Change payment method`} + > + +
+

+ Upon clicking confirm, all future charges will be deducted from the selected payment + method. There are no immediate charges. Changing the payment method for this project + does not affect the payment method for other projects. +

+

+ To remove unused or expired payment methods, head to your{' '} + + + organization's billing settings + + + . +

+
+ +
+
+
+
+ + ) +} + +export default SubscriptionPaymentMethod diff --git a/studio/data/organizations/organization-payment-method-delete-mutation.ts b/studio/data/organizations/organization-payment-method-delete-mutation.ts new file mode 100644 index 00000000000..de0eb95fd1b --- /dev/null +++ b/studio/data/organizations/organization-payment-method-delete-mutation.ts @@ -0,0 +1,48 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' +import { ResponseError } from 'types' + +export type OrganizationPaymentMethodDeleteVariables = { + slug: string + cardId: string +} + +export async function deleteOrganizationMember({ + slug, + cardId, +}: OrganizationPaymentMethodDeleteVariables) { + const response = await delete_(`${API_URL}/organizations/${slug}/payments`, { card_id: cardId }) + if (response.error) throw response.error + return response +} + +type OrganizationPaymentMethodDeleteData = Awaited> + +export const useOrganizationPaymentMethodDeleteMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + OrganizationPaymentMethodDeleteData, + unknown, + OrganizationPaymentMethodDeleteVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OrganizationPaymentMethodDeleteData, + unknown, + OrganizationPaymentMethodDeleteVariables + >((vars) => deleteOrganizationMember(vars), { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(organizationKeys.paymentMethods(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/organizations/organization-payment-methods-query.ts b/studio/data/organizations/organization-payment-methods-query.ts index 86a4369bdf1..6118492f797 100644 --- a/studio/data/organizations/organization-payment-methods-query.ts +++ b/studio/data/organizations/organization-payment-methods-query.ts @@ -5,7 +5,7 @@ import { useCallback } from 'react' import { organizationKeys } from './keys' export type OrganizationPaymentMethodsVariables = { slug?: string } -export type OrganizationPaymentMethodsResponse = { +export type OrganizationPaymentMethod = { id: string customer: string type: string @@ -29,7 +29,7 @@ export type OrganizationPaymentMethodsResponse = { name: string | null phone: string | null } -}[] +} export async function getOrganizations( { slug }: OrganizationPaymentMethodsVariables, @@ -40,7 +40,7 @@ export async function getOrganizations( const response = await get(`${API_URL}/organizations/${slug}/payments`, { signal }) if (response.error) throw response.error - return response.data as OrganizationPaymentMethodsResponse + return response.data as OrganizationPaymentMethod[] } export type OrganizationPaymentMethodsData = Awaited> diff --git a/studio/data/subscriptions/org-subscription-update-mutation.ts b/studio/data/subscriptions/org-subscription-update-mutation.ts new file mode 100644 index 00000000000..f61c9ec923e --- /dev/null +++ b/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -0,0 +1,62 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { put } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { subscriptionKeys } from './keys' + +export type SubscriptionTier = + | 'tier_free' + | 'tier_pro' + | 'tier_payg' + | 'tier_team' + | 'tier_enterprise' + +export type OrgSubscriptionUpdateVariables = { + slug: string + paymentMethod?: string + tier: SubscriptionTier +} + +export type OrgSubscriptionUpdateResponse = { + error?: any +} + +export async function updateOrgSubscription({ + slug, + tier, + paymentMethod, +}: OrgSubscriptionUpdateVariables) { + if (!slug) throw new Error('slug is required') + if (!tier) throw new Error('tier is required') + + const payload: { tier: string; payment_method?: string } = { tier } + if (paymentMethod !== undefined) payload.payment_method = paymentMethod + + const response = await put(`${API_URL}/organizations/${slug}/billing/subscription`, payload) + if (response.error) throw response.error + + return response as OrgSubscriptionUpdateResponse +} + +type OrgSubscriptionUpdateData = Awaited> + +export const useOrgSubscriptionUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateOrgSubscription(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} From 23f2e9edf703388732b6a437a5d11086a924ea67 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 3 Jul 2023 12:42:31 +0800 Subject: [PATCH 08/21] Improve payment method UI --- .../ChangePaymentMethodModal.tsx | 89 ++++++++ .../PaymentMethods/PaymentMethods.tsx | 201 ++++++++++-------- 2 files changed, 199 insertions(+), 91 deletions(-) create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx new file mode 100644 index 00000000000..3ad2afd3d22 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx @@ -0,0 +1,89 @@ +import { useParams } from 'common' +import { OrganizationPaymentMethod } from 'data/organizations/organization-payment-methods-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { + SubscriptionTier, + useOrgSubscriptionUpdateMutation, +} from 'data/subscriptions/org-subscription-update-mutation' +import { useStore } from 'hooks' +import { Button, Modal } from 'ui' + +export interface ChangePaymentMethodModalProps { + selectedPaymentMethod?: OrganizationPaymentMethod + onClose: () => void +} + +const ChangePaymentMethodModal = ({ + selectedPaymentMethod, + onClose, +}: ChangePaymentMethodModalProps) => { + const { ui } = useStore() + const { slug } = useParams() + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { mutateAsync: updateOrgSubscription, isLoading: isUpdating } = + useOrgSubscriptionUpdateMutation() + + const onConfirmUpdate = async () => { + if (!slug) return console.error('Slug is required') + if (!subscription) return console.error('Subscription is required') + if (!selectedPaymentMethod) return console.error('Card ID is required') + + const selectedTier = + subscription.plan.id === 'pro' && subscription.usage_billing_enabled + ? 'tier_payg' + : `tier_${subscription.plan.id}` + + try { + await updateOrgSubscription({ + slug, + tier: selectedTier as SubscriptionTier, + paymentMethod: selectedPaymentMethod.id, + }) + ui.setNotification({ + category: 'success', + message: `Successfully removed payment method ending with ${selectedPaymentMethod.card.last4}`, + }) + onClose() + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to delete payment method: ${error.message}`, + }) + } + } + + return ( + onClose()} + customFooter={ +
+ + +
+ } + > +
+ +

+ Upon clicking confirm, all future charges will be deducted from the card ending with{' '} + {selectedPaymentMethod?.card.last4}. There are no immediate charges. +

+
+
+
+ ) +} + +export default ChangePaymentMethodModal diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx index 7e0dc88e4f2..c49c416c316 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx @@ -8,30 +8,34 @@ import { FormPanel, FormSection, FormSectionContent, FormSectionLabel } from 'co import NoPermission from 'components/ui/NoPermission' import { organizationKeys } from 'data/organizations/keys' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' -import { useCheckPermissions, useStore } from 'hooks' -import { getURL } from 'lib/helpers' -import { Badge, Button, Dropdown, IconCreditCard, IconMoreHorizontal, IconPlus, Input } from 'ui' -import { BASE_PATH } from 'lib/constants' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useCheckPermissions, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { getURL } from 'lib/helpers' +import { Badge, Button, Dropdown, IconCreditCard, IconMoreHorizontal, IconPlus } from 'ui' +import ChangePaymentMethodModal from './ChangePaymentMethodModal' import DeletePaymentMethodModal from './DeletePaymentMethodModal' - -// [TODO] Implement change credit card for org subscription logic +import AlertError from 'components/ui/AlertError' const PaymentMethods = () => { const { ui } = useStore() const { slug } = useParams() const queryClient = useQueryClient() - const [selectedMethodForDefault, setSelectedMethodForDefault] = useState() + const [selectedMethodForUse, setSelectedMethodForUse] = useState() const [selectedMethodToDelete, setSelectedMethodToDelete] = useState() const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false) const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug }) const { data: paymentMethods, + error, isLoading, isError, isSuccess, } = useOrganizationPaymentMethodsQuery({ slug }) + const sortedPaymentMethods = + paymentMethods?.sort((a, b) => (a.id === subscription?.payment_method_id ? -1 : 0)) ?? [] + console.log({ sortedPaymentMethods }) const canReadPaymentMethods = useCheckPermissions( PermissionAction.BILLING_READ, @@ -65,92 +69,102 @@ const PaymentMethods = () => { {!canReadPaymentMethods ? ( ) : ( - - {!canUpdatePaymentMethods ? ( -

- You need additional permissions to manage payment methods -

- ) : ( -
- )} - -
- } - > - - - {(paymentMethods?.length ?? 0) === 0 ? ( -
- -

No payment methods

+ <> + {isError && ( + + )} + {isSuccess && ( + + {!canUpdatePaymentMethods ? ( +

+ You need additional permissions to manage payment methods +

+ ) : ( +
+ )} +
- ) : ( -
- {paymentMethods?.map((paymentMethod) => { - const activePaymentMethod = - subscription?.payment_method_id === paymentMethod.id - return ( -
-
- Credit card brand -

- **** **** **** {paymentMethod.card.last4} -

-

- Expires: {paymentMethod.card.exp_month}/ - {paymentMethod.card.exp_year} -

- {activePaymentMethod && Active} -
- {canUpdatePaymentMethods ? ( - setSelectedMethodForDefault(paymentMethod)} - > - Make default - , - setSelectedMethodToDelete(paymentMethod)} - > - Delete - , - ]} + } + > + + + {(paymentMethods?.length ?? 0) === 0 ? ( +
+ +

No payment methods

+
+ ) : ( +
+ {sortedPaymentMethods?.map((paymentMethod) => { + const isActive = subscription?.payment_method_id === paymentMethod.id + return ( +
-
- ) - })} -
- )} -
-
- +
+ Credit card brand +

+ **** **** **** {paymentMethod.card.last4} +

+

+ Expires: {paymentMethod.card.exp_month}/ + {paymentMethod.card.exp_year} +

+ {isActive && Active} +
+ {canUpdatePaymentMethods && !isActive ? ( + setSelectedMethodForUse(paymentMethod)} + > + Use this card + , + , + setSelectedMethodToDelete(paymentMethod)} + > + Delete card + , + ]} + > +
+ ) + })} +
+ )} + + +
+ )} + )} @@ -169,6 +183,11 @@ const PaymentMethods = () => { }} /> + setSelectedMethodForUse(undefined)} + /> + setSelectedMethodToDelete(undefined)} From df188a1189778b33721bc76f72fcf7bf9ce24910 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 3 Jul 2023 13:24:26 +0800 Subject: [PATCH 09/21] Add plan upgrade side panel --- .../BillingSettings.constants.ts | 16 + .../PaymentMethods/PaymentMethods.tsx | 1 - .../Subscription/EnterpriseCard.tsx | 62 +++ .../Subscription/ExitSurveyModal.tsx | 235 +++++++++++ .../Subscription/MembersExceedLimitModal.tsx | 59 +++ .../Subscription/PlanUpdateSidePanel.tsx | 380 ++++++++++++++++++ .../BillingSettingsV2/Subscription/README.md | 7 - .../Subscription/Subscription.tsx | 262 ++++++------ studio/data/subscriptions/keys.ts | 6 +- studio/data/subscriptions/org-plans-query.ts | 55 +++ studio/state/organization-settings.ts | 15 + 11 files changed, 960 insertions(+), 138 deletions(-) create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.constants.ts create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/Subscription/EnterpriseCard.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/Subscription/ExitSurveyModal.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/Subscription/MembersExceedLimitModal.tsx create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx delete mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/Subscription/README.md create mode 100644 studio/data/subscriptions/org-plans-query.ts create mode 100644 studio/state/organization-settings.ts diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.constants.ts b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.constants.ts new file mode 100644 index 00000000000..3e16e7b2138 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.constants.ts @@ -0,0 +1,16 @@ +export const CANCELLATION_REASONS = [ + 'Pricing', + "My project isn't getting traction", + 'Poor customer service', + 'Missing feature', + "I didn't see the value", + "Supabase didn't meet my needs", + 'Dashboard is too complicated', + 'Postgres is too complicated', + 'Problem not solved', + 'Bug issues', + 'I decided to use something else', + 'My work has finished/discontinued', + 'I’m migrating to/starting a new project', + 'None of the above', +] diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx index c49c416c316..18ef727fa21 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx @@ -35,7 +35,6 @@ const PaymentMethods = () => { } = useOrganizationPaymentMethodsQuery({ slug }) const sortedPaymentMethods = paymentMethods?.sort((a, b) => (a.id === subscription?.payment_method_id ? -1 : 0)) ?? [] - console.log({ sortedPaymentMethods }) const canReadPaymentMethods = useCheckPermissions( PermissionAction.BILLING_READ, diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/EnterpriseCard.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/EnterpriseCard.tsx new file mode 100644 index 00000000000..af9608f0dd0 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/EnterpriseCard.tsx @@ -0,0 +1,62 @@ +import clsx from 'clsx' +import { Button, IconCheck } from 'ui' +import { PricingInformation } from 'shared-data' + +export interface EnterpriseCardProps { + plan: PricingInformation + isCurrentPlan: boolean +} + +const EnterpriseCard = ({ plan, isCurrentPlan }: EnterpriseCardProps) => { + return ( +
+
+
+

{plan.name}

+ {isCurrentPlan ? ( +
+ Current plan +
+ ) : plan.nameBadge ? ( +
+ {plan.nameBadge} +
+ ) : null} +
+ +

{plan.description}

+ + + + +
+ +
+
    + {plan.features.map((feature) => ( +
  • +
  • + ))} +
+ + + + +
+
+ ) +} + +export default EnterpriseCard diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/ExitSurveyModal.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/ExitSurveyModal.tsx new file mode 100644 index 00000000000..ff7dd6b1552 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/ExitSurveyModal.tsx @@ -0,0 +1,235 @@ +import HCaptcha from '@hcaptcha/react-hcaptcha' +import { useQueryClient } from '@tanstack/react-query' +import { includes, without } from 'lodash' +import { useRouter } from 'next/router' +import { useReducer, useRef, useState } from 'react' + +import { useParams } from 'common' +import { setProjectStatus } from 'data/projects/projects-query' +import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' +import { useProjectSubscriptionUpdateMutation } from 'data/subscriptions/project-subscription-update-mutation' +import { useFlag, useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { Button, Input, Modal } from 'ui' +import { CANCELLATION_REASONS } from '../BillingSettings.constants' +import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' + +export interface ExitSurveyModalProps { + visible: boolean + onClose: (success?: boolean) => void +} + +// [Joshen] For context - Exit survey is only when going to free plan from a paid plan +// [Joshen TODO] Remove all contexts of projects - i'm presuming we still want the exit survey + +const ExitSurveyModal = ({ visible, onClose }: ExitSurveyModalProps) => { + const queryClient = useQueryClient() + const { ui } = useStore() + const { ref: projectRef } = useParams() + const captchaRef = useRef(null) + const router = useRouter() + + const [isSubmitting, setIsSubmitting] = useState(false) + const [message, setMessage] = useState('') + const [captchaToken, setCaptchaToken] = useState(null) + const [selectedReasons, dispatchSelectedReasons] = useReducer(reducer, []) + + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + const { data: addons } = useProjectAddonsQuery({ projectRef }) + const { mutateAsync: updateSubscriptionTier } = useProjectSubscriptionUpdateMutation() + + const subscriptionAddons = addons?.selected_addons ?? [] + const hasComputeInstance = subscriptionAddons.find((addon) => addon.type === 'compute_instance') + + function reducer(state: any, action: any) { + if (includes(state, action.target.value)) { + return without(state, action.target.value) + } else { + return [...state, action.target.value] + } + } + + const resetCaptcha = () => { + setCaptchaToken(null) + captchaRef.current?.resetCaptcha() + } + + const onSubmit = async () => { + if (selectedReasons.length === 0) { + return ui.setNotification({ + category: 'error', + message: 'Please select at least one reason for canceling your subscription', + }) + } + + setIsSubmitting(true) + let token = captchaToken + + try { + if (!token) { + const captchaResponse = await captchaRef.current?.execute({ async: true }) + token = captchaResponse?.response ?? null + await downgradeProject() + } + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to downgrade project: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + const downgradeProject = async (values?: any) => { + // Update the subscription first, followed by posting the exit survey if successful + // If compute instance is present within the existing subscription, then a restart will be triggered + if (!projectRef) return console.error('Project ref is required') + + try { + await updateSubscriptionTier({ projectRef, tier: 'tier_free' }) + resetCaptcha() + } catch (error: any) { + return ui.setNotification({ + error, + category: 'error', + message: `Failed to cancel subscription: ${error.message}`, + }) + } + + try { + const feedbackRes = await post(`${API_URL}/feedback/downgrade`, { + projectRef, + reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''), + additionalFeedback: message, + exitAction: 'downgrade', + }) + if (feedbackRes.error) throw feedbackRes.error + } catch (error: any) { + return ui.setNotification({ + error, + category: 'info', + message: `Failed to submit exit survey: ${error.message}`, + }) + } finally { + ui.setNotification({ + category: 'success', + duration: hasComputeInstance ? 8000 : 4000, + message: hasComputeInstance + ? 'Your project has been downgraded and is currently restarting to update its instance size' + : 'Successfully downgraded project to the free plan', + }) + if (hasComputeInstance) { + setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING) + router.push(`/project/${projectRef}`) + } + onClose(true) + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + } + } + + return ( + <> +
+ { + setCaptchaToken(token) + if (document !== undefined) document.body.classList.remove('!pointer-events-auto') + }} + onExpire={() => setCaptchaToken(null)} + onOpen={() => { + // [Joshen] This is to ensure that hCaptcha popup remains clickable + if (document !== undefined) document.body.classList.add('!pointer-events-auto') + }} + onClose={() => { + if (document !== undefined) document.body.classList.remove('!pointer-events-auto') + }} + /> +
+ + + +
+

+ We always strive to improve Supabase as much as we can. Please let us know the reasons + you are canceling your subscription so that we can improve in the future. +

+
+
+ {CANCELLATION_REASONS.map((option) => { + const active = selectedReasons.find((x) => x === option) + return ( + + ) + })} +
+
+ setMessage(event.target.value)} + label="Anything else that we can improve on?" + /> +
+
+
+
+ +
+

+ The unused amount for the remaining of your billing cycle will be refunded as credits +

+
+ + + + +
+
+
+ + ) +} + +export default ExitSurveyModal diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/MembersExceedLimitModal.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/MembersExceedLimitModal.tsx new file mode 100644 index 00000000000..7f64080e394 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/MembersExceedLimitModal.tsx @@ -0,0 +1,59 @@ +import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' +import { useSelectedOrganization } from 'hooks' +import { Button, Modal } from 'ui' + +// [Joshen TODO] Check if we still need this? + +export interface MembersExceedLimitModalProps { + visible: boolean + onClose: () => void +} + +const MembersExceedLimitModal = ({ visible, onClose }: MembersExceedLimitModalProps) => { + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug + const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) + + return ( + +
+ +
+

+ The following members have reached their maximum limits for the number of active free + plan projects within organizations where they are an administrator or owner: +

+
    + {(membersExceededLimit || []).map((member, idx: number) => ( +
  • + {member.username || member.primary_email} (Limit: {member.free_project_limit} free + projects) +
  • + ))} +
+

+ These members will need to either delete, pause, or upgrade one or more of these + projects before you're able to downgrade this project. +

+
+
+ + +
+ +
+
+
+
+ ) +} + +export default MembersExceedLimitModal diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx new file mode 100644 index 00000000000..54c8044cb7e --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx @@ -0,0 +1,380 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import clsx from 'clsx' +import Link from 'next/link' +import { useEffect, useState } from 'react' + +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useOrgPlansQuery } from 'data/subscriptions/org-plans-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' +import Telemetry from 'lib/telemetry' +import { useRouter } from 'next/router' +import { plans as subscriptionsPlans } from 'shared-data/plans' +import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' +import { Alert, Button, IconCheck, IconExternalLink, Modal, SidePanel } from 'ui' +import EnterpriseCard from './EnterpriseCard' +import ExitSurveyModal from './ExitSurveyModal' +import MembersExceedLimitModal from './MembersExceedLimitModal' +import PaymentMethodSelection from './PaymentMethodSelection' + +// [Joshen TODO] Need to remove all contexts of "projects" + +const PlanUpdateSidePanel = () => { + const { ui } = useStore() + const router = useRouter() + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug + + const [isSubmitting, setIsSubmitting] = useState(false) + const [showExitSurvey, setShowExitSurvey] = useState(false) + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() + const [showDowngradeError, setShowDowngradeError] = useState(false) + const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>() + + const canUpdateSubscription = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.subscriptions' + ) + + const snap = useOrgSettingsPageStateSnapshot() + const visible = snap.panelKey === 'subscriptionPlan' + const onClose = () => snap.setPanelKey(undefined) + + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { data: plans, isLoading: isLoadingPlans } = useOrgPlansQuery({ orgSlug: slug }) + const { mutateAsync: updateOrgSubscription } = useOrgSubscriptionUpdateMutation() + // const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) // [Joshen] Still need? + + const availablePlans = plans ?? [] + const subscriptionAddons: any[] = [] // [Joshen] Still need? + + // const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 + const hasMembersExceedingFreeTierLimit = false // [Joshen] Still need? + const selectedTierMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier) + const selectedPlanMeta = availablePlans.find( + (plan) => plan.id === selectedTier?.split('tier_')[1] + ) + + useEffect(() => { + if (visible) { + setSelectedTier(undefined) + Telemetry.sendActivity( + { + activity: 'Side Panel Viewed', + source: 'Dashboard', + data: { + title: 'Change Subscription Plan', + section: 'Subscription plan', + }, + }, + router + ) + } + }, [visible]) + + const onConfirmDowngrade = () => { + setSelectedTier(undefined) + if (hasMembersExceedingFreeTierLimit) { + setShowDowngradeError(true) + } else { + setShowExitSurvey(true) + } + } + + const onUpdateSubscription = async () => { + if (!slug) return console.error('org slug is required') + if (!selectedTier) return console.error('Selected plan is required') + if (!selectedPaymentMethod) { + return ui.setNotification({ category: 'error', message: 'Please select a payment method' }) + } + + try { + setIsSubmitting(true) + await updateOrgSubscription({ + slug, + tier: selectedTier, + paymentMethod: selectedPaymentMethod, + }) + ui.setNotification({ + category: 'success', + message: `Successfully updated subscription to ${selectedTierMeta?.name}!`, + }) + setSelectedTier(undefined) + onClose() + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Unable to update subscription: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + <> + onClose()} + header={ +
+

Change subscription plan

+ + + + + +
+ } + > + +
+ {subscriptionsPlans.map((plan) => { + const planMeta = availablePlans.find((p) => p.id === plan.id.split('tier_')[1]) + const tierMeta = subscriptionsPlans.find((it) => it.id === plan.id) + const price = planMeta?.price ?? 0 + const isDowngradeOption = planMeta?.change_type === 'downgrade' + const isCurrentPlan = planMeta?.id === subscription?.plan?.id + + if (plan.id === 'tier_enterprise') { + return + } + + return ( +
+
+
+

{plan.name}

+ {isCurrentPlan ? ( +
+ Current plan +
+ ) : plan.nameBadge ? ( +
+ {plan.nameBadge} +
+ ) : ( + <> + )} +
+
+ {(price ?? 0) > 0 &&

From

} + {isLoadingPlans ? ( +
+ +
+ ) : ( +

${price}

+ )} +

{tierMeta?.costUnit}

+
+
+
+ {tierMeta?.warning} +
+
+ {isCurrentPlan ? ( + + ) : plan.id !== PRICING_TIER_PRODUCT_IDS.TEAM ? ( + + +
+ +
+
+ {!canUpdateSubscription ? ( + + + +
+ + You do not have permission to change the subscription plan. + +
+
+
+ ) : null} +
+ ) : ( + + + + + + )} + +
+ +
    + {plan.features.map((feature) => ( +
  • +
    +
    +

    {feature}

    +
  • + ))} +
+
+ + {plan.footer && ( +
+

{plan.footer}

+
+ )} +
+ ) + })} +
+ + + + setSelectedTier(undefined)} + onConfirm={onConfirmDowngrade} + header={`Confirm to downgrade to ${selectedTierMeta?.name}`} + > + {/* [JOSHEN] We could make this better by only showing a danger warning if the project is already above the free plan limits */} + +
+ +

+ If you're already past the limits of the free plan, your project could become + unresponsive or enter read only mode. +

+ {subscriptionAddons.length > 0 && ( + <> +

+ Your project's add ons will also be removed, which includes: +

+
    + {subscriptionAddons.map((addon) => ( +
  • + {addon.variant.name}{' '} + {addon.type === 'compute_instance' + ? 'compute instance' + : addon.type === 'pitr' + ? 'PITR' + : ''} +
  • + ))} +
+ + )} +
+
+
+
+ + setSelectedTier(undefined)} + onConfirm={onUpdateSubscription} + overlayClassName="pointer-events-none" + header={`Confirm to upgrade to ${selectedTierMeta?.name}`} + > + +
+

+ Upon clicking confirm, the amount of ${selectedPlanMeta?.price ?? 'Unknown'} will be + added to your monthly invoice and your credit card will be charged immediately. + Changing the plan resets your billing cycle and may result in a prorated charge for + previous usage. +

+

+ You will also be able to change your project's add-ons after upgrading your project's + plan. +

+
+ +
+
+
+
+ + setShowDowngradeError(false)} + /> + + { + setShowExitSurvey(false) + if (success) onClose() + }} + /> + + ) +} + +export default PlanUpdateSidePanel diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/README.md b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/README.md deleted file mode 100644 index 0ed42b4e4bc..00000000000 --- a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/README.md +++ /dev/null @@ -1,7 +0,0 @@ -### Disclaimer - -[Joshen] Just FYI a lot of these are lifted from `components/interfaces/BillingV2/Subscription` since they are -more or less the same, in fact after migrating to Org billing completely, we will probably deprecate the BillingV2 folder -at the project scope. - -- Probably also need to update support form to allow selecting an organization, not just project diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx index 032612c1f29..dc5eed476fd 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx @@ -10,9 +10,12 @@ import { useFlag } from 'hooks' import { Alert, Button, IconExternalLink } from 'ui' import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' import SubscriptionPaymentMethod from './SubscriptionPaymentMethod' +import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' +import PlanUpdateSidePanel from './PlanUpdateSidePanel' const Subscription = () => { const { slug } = useParams() + const snap = useOrgSettingsPageStateSnapshot() const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { @@ -34,139 +37,142 @@ const Subscription = () => { !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id ?? '') return ( - -
-

Subscription Plan

-
-

More information

- -
-
- - } - > - - {isError && } - - {isSuccess && ( -
-
-

This organization is currently on the plan:

-

{currentPlan?.name ?? 'Unknown'}

-
- -
- - - - {!canChangeTier && - (projectUpdateDisabled ? ( - - We have temporarily disabled project and subscription changes - our engineers - are working on a fix. - - ) : ( - - - - - - -
, - ]} - > - Please contact us if you'd like to change your plan. - - ))} -
- - {!subscription?.usage_billing_enabled && ( - {}}> - Upgrade Plan - - ) : ( - // [Joshen TODO]: Implement - - ) - } - > -

- When this organization exceeds its{' '} - - - included usage quotas + <> + +

+

Subscription Plan

+
+

More information

+
+ + +
+

Pricing

+ +
- , its projects may become unresponsive.{' '} - {currentPlan?.id === 'free' ? ( -

- If you wish to exceed the included usage, you should upgrade to a paid plan. -

- ) : ( -

- You currently have Spend Cap enabled - when you exceed your plan's limit, you - will experience restrictions. To scale seamlessly and pay for over-usage, you - can adjust your Cost Control settings. -

- )} +
+
+
+ + } + > + + {isError && } + + {isSuccess && ( +
+
+

This organization is currently on the plan:

+

+ {currentPlan?.name ?? 'Unknown'}

- - )} +
- +
+ + + + {!canChangeTier && + (projectUpdateDisabled ? ( + + We have temporarily disabled project and subscription changes - our engineers + are working on a fix. + + ) : ( + + + + + + +
, + ]} + > + Please contact us if you'd like to change your plan. + + ))} +
- {subscription !== undefined && ( - - )} -
- )} - - + {!subscription?.usage_billing_enabled && ( + snap.setPanelKey('subscriptionPlan')}> + Upgrade Plan + + ) : ( + + ) + } + > +

+ When this organization exceeds its{' '} + + + included usage quotas + + + , its projects may become unresponsive.{' '} + {currentPlan?.id === 'free' ? ( +

+ If you wish to exceed the included usage, you should upgrade to a paid plan. +

+ ) : ( +

+ You currently have Spend Cap enabled - when you exceed your plan's limit, + you will experience restrictions. To scale seamlessly and pay for + over-usage, you can adjust your Cost Control settings. +

+ )} +

+
+ )} + + + + {subscription !== undefined && ( + + )} +
+ )} +
+
+ + ) } diff --git a/studio/data/subscriptions/keys.ts b/studio/data/subscriptions/keys.ts index 16b4998d66d..94be5ab2794 100644 --- a/studio/data/subscriptions/keys.ts +++ b/studio/data/subscriptions/keys.ts @@ -1,10 +1,12 @@ // [Joshen TODO] To remove subscription V2 after v2 is launched and stable to deprecate old billing ui + endpoints export const subscriptionKeys = { - subscriptionV2: (projectRef: string | undefined) => - ['projects', projectRef, 'subscriptionV2'] as const, orgSubscription: (orgSlug: string | undefined) => ['organizations', orgSlug, 'subscription'] as const, + orgPlans: (orgSlug: string | undefined) => ['organizations', orgSlug, 'plans'] as const, + + subscriptionV2: (projectRef: string | undefined) => + ['projects', projectRef, 'subscriptionV2'] as const, addons: (projectRef: string | undefined) => ['projects', projectRef, 'addons'] as const, plans: (projectRef: string | undefined) => ['projects', projectRef, 'plans'] as const, preview: (projectRef: string | undefined) => diff --git a/studio/data/subscriptions/org-plans-query.ts b/studio/data/subscriptions/org-plans-query.ts new file mode 100644 index 00000000000..acfcae395f2 --- /dev/null +++ b/studio/data/subscriptions/org-plans-query.ts @@ -0,0 +1,55 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { subscriptionKeys } from './keys' + +export type OrgPlansVariables = { + orgSlug?: string +} + +export type OrgPlansResponse = { + id: 'free' | 'pro' | 'team' | 'enterprise' + name: string + price: number + is_current: boolean + change_type: 'downgrade' | 'upgrade' | 'none' + effective_at: string +}[] + +export async function getOrgPlans({ orgSlug }: OrgPlansVariables, signal?: AbortSignal) { + if (!orgSlug) throw new Error('orgSlug is required') + + const response = await get(`${API_URL}/organizations/${orgSlug}/billing/plans`, { signal }) + if (response.error) throw response.error + + return response.plans as OrgPlansResponse +} + +export type OrgPlansData = Awaited> +export type OrgPlansError = unknown + +export const useOrgPlansQuery = ( + { orgSlug }: OrgPlansVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + subscriptionKeys.orgPlans(orgSlug), + ({ signal }) => getOrgPlans({ orgSlug }, signal), + { + enabled: enabled && typeof orgSlug !== 'undefined', + ...options, + } + ) + +export const useOrgPlansPrefetch = ({ orgSlug }: OrgPlansVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (orgSlug) { + client.prefetchQuery(subscriptionKeys.orgPlans(orgSlug), ({ signal }) => + getOrgPlans({ orgSlug }, signal) + ) + } + }, [orgSlug]) +} diff --git a/studio/state/organization-settings.ts b/studio/state/organization-settings.ts new file mode 100644 index 00000000000..a6f67c58f78 --- /dev/null +++ b/studio/state/organization-settings.ts @@ -0,0 +1,15 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export type ORG_SETTINGS_PANEL_KEYS = undefined | 'subscriptionPlan' | 'costControl' + +export const orgSettingsPageState = proxy({ + panelKey: undefined as ORG_SETTINGS_PANEL_KEYS, + setPanelKey: (key: ORG_SETTINGS_PANEL_KEYS) => { + orgSettingsPageState.panelKey = key + }, +}) + +export const getOrgSettingsPageStateSnapshot = () => snapshot(orgSettingsPageState) + +export const useOrgSettingsPageStateSnapshot = (options?: Parameters[1]) => + useSnapshot(orgSettingsPageState, options) From 3ab0e895c0131c7c1fb57eb35c7e249e80a5a1cb Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 3 Jul 2023 13:29:38 +0800 Subject: [PATCH 10/21] add spend cap side panel --- .../CostControl/CostControl.tsx | 219 +++++++------ .../CostControl/SpendCapSidePanel.tsx | 308 ++++++++++++++++++ .../Subscription/PlanUpdateSidePanel.tsx | 1 + 3 files changed, 421 insertions(+), 107 deletions(-) create mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/CostControl/SpendCapSidePanel.tsx diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx index 90ea7c45e5f..82bc7c6571b 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx @@ -2,22 +2,22 @@ import Link from 'next/link' import { useParams, useTheme } from 'common' import { BASE_PATH } from 'lib/constants' -import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' import { Alert, Button, IconExternalLink } from 'ui' -// import SpendCapSidePanel from './SpendCapSidePanel' +import SpendCapSidePanel from './SpendCapSidePanel' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useFlag } from 'hooks' +import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' export interface CostControlProps {} const CostControl = ({}: CostControlProps) => { const { slug } = useParams() - const snap = useSubscriptionPageStateSnapshot() - const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { isDarkMode } = useTheme() + const snap = useOrgSettingsPageStateSnapshot() + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { data: subscription, isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) const currentPlan = subscription?.plan @@ -27,113 +27,118 @@ const CostControl = ({}: CostControlProps) => { !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '') return ( - -
-
-

Cost Control

-

- Control whether to use beyond your plans included quota -

-
-
-

More information

+ <> + + -
- - } - > - -
- {['team', 'enterprise'].includes(currentPlan?.id || '') ? ( - - {currentPlan?.name || ''} plan requires you to have spend cap off at all times. Your - projects will never become unresponsive. Only when your{' '} - - - included usage - - {' '} - is exceeded will you be charged for any additional usage. - - ) : ( -

- You can control whether your organization is charged for additional usage beyond the{' '} - - - included quota - - {' '} - of your subscription plan. If you need to go beyond the included quota, simply switch - off your spend cap to pay for additional usage. -

- )} - -
-
-
- Spend Cap -
-
-
-

Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'}

-

- {isUsageBillingEnabled ? ( - You will be charged for any usage above the included quota. - ) : ( - - You won't be charged any extra for usage. However, your projects could become - unresponsive or enter read only mode if you exceed the included quota. - - )} -

- {isUsageBillingEnabled && ( -

- Your projects will never become unresponsive. Only when your usage reaches the - quota limit will you be charged for any excess usage. +

Cost Control

+

+ Control whether to use beyond your plans included quota

- )} - - - +
+
+

More information

+ +
+
+ + } + > + +
+ {['team', 'enterprise'].includes(currentPlan?.id || '') ? ( + + {currentPlan?.name || ''} plan requires you to have spend cap off at all times. Your + projects will never become unresponsive. Only when your{' '} + + + included usage + + {' '} + is exceeded will you be charged for any additional usage. + + ) : ( +

+ You can control whether your organization is charged for additional usage beyond the{' '} + + + included quota + + {' '} + of your subscription plan. If you need to go beyond the included quota, simply + switch off your spend cap to pay for additional usage. +

+ )} + +
+
+
+ Spend Cap +
+
+
+

+ Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'} +

+

+ {isUsageBillingEnabled ? ( + You will be charged for any usage above the included quota. + ) : ( + + You won't be charged any extra for usage. However, your projects could become + unresponsive or enter read only mode if you exceed the included quota. + + )} +

+ {isUsageBillingEnabled && ( +

+ Your projects will never become unresponsive. Only when your usage reaches the + quota limit will you be charged for any excess usage. +

+ )} + + + +
-
-
- + + + + ) } diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/SpendCapSidePanel.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/SpendCapSidePanel.tsx new file mode 100644 index 00000000000..3013c81b829 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/SpendCapSidePanel.tsx @@ -0,0 +1,308 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import clsx from 'clsx' +import { useParams, useTheme } from 'common' +import Table from 'components/to-be-cleaned/Table' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' +import { useCheckPermissions, useStore } from 'hooks' +import { BASE_PATH, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' +import Telemetry from 'lib/telemetry' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { pricing } from 'shared-data/pricing' +import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' +import { Alert, Button, Collapsible, IconChevronRight, IconExternalLink, SidePanel } from 'ui' + +const SPEND_CAP_OPTIONS: { + name: string + value: 'on' | 'off' + imageUrl: string + imageUrlLight: string +}[] = [ + { + name: 'Spend cap enabled', + value: 'on', + imageUrl: `${BASE_PATH}/img/spend-cap-on.png`, + imageUrlLight: `${BASE_PATH}/img/spend-cap-on--light.png`, + }, + { + name: 'Spend cap disabled', + value: 'off', + imageUrl: `${BASE_PATH}/img/spend-cap-off.png`, + imageUrlLight: `${BASE_PATH}/img/spend-cap-off--light.png`, + }, +] + +const SpendCapSidePanel = () => { + const { ui } = useStore() + const router = useRouter() + const { slug } = useParams() + const { isDarkMode } = useTheme() + + const [showUsageCosts, setShowUsageCosts] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [selectedOption, setSelectedOption] = useState<'on' | 'off'>() + + const canUpdateSpendCap = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.subscriptions' + ) + + const snap = useOrgSettingsPageStateSnapshot() + const visible = snap.panelKey === 'costControl' + const onClose = () => snap.setPanelKey(undefined) + + const { data: subscription, isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { mutateAsync: updateOrgSubscription } = useOrgSubscriptionUpdateMutation() + + const isFreePlan = subscription?.plan?.id === 'free' + const isSpendCapOn = !subscription?.usage_billing_enabled + const isTurningOnCap = !isSpendCapOn && selectedOption === 'on' + const hasChanges = selectedOption !== (isSpendCapOn ? 'on' : 'off') + + useEffect(() => { + if (visible && subscription !== undefined) { + setSelectedOption(isSpendCapOn ? 'on' : 'off') + Telemetry.sendActivity( + { + activity: 'Side Panel Viewed', + source: 'Dashboard', + data: { + title: 'Spend cap', + section: 'Cost Control', + }, + }, + router + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, isLoading, subscription, isSpendCapOn]) + + const onConfirm = async () => { + if (!slug) return console.error('Org slug is required') + + try { + const tier = ( + selectedOption === 'on' ? PRICING_TIER_PRODUCT_IDS.PRO : PRICING_TIER_PRODUCT_IDS.PAYG + ) as 'tier_pro' | 'tier_payg' + setIsSubmitting(true) + await updateOrgSubscription({ slug, tier }) + ui.setNotification({ + category: 'success', + message: `Successfully ${isTurningOnCap ? 'enabled' : 'disabled'} spend cap`, + }) + onClose() + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Unable to toggle spend cap: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + const billingMetricCategories: (keyof typeof pricing)[] = [ + 'database', + 'auth', + 'storage', + 'realtime', + 'edge_functions', + ] + + return ( + +

Spend cap

+ + + + + +
+ } + tooltip={!canUpdateSpendCap ? 'You do not have permission to update spend cap' : undefined} + > + +
+

+ Use the spend cap to manage project usage and costs, and control whether the project can + exceed the included quota allowance of any billed line item in a billing cycle +

+ + + +
+ +

+ How are each resource charged after exceeding the included quota? +

+
+
+ + + +

Item

+
+ +

Rate

+
+ + } + body={billingMetricCategories.map((categoryId) => { + const category = pricing[categoryId] + const usageItems = category.features.filter((it: any) => it.usage_based) + + return ( + <> + + +

{category.title}

+
+ {null} +
+ {usageItems.map((item: any) => { + return ( + + +

{item.title}

+
+ +

+ {item.plans[subscription?.plan?.id || 'pro']} +

+
+
+ ) + })} + + ) + })} + /> + + + + {isFreePlan && ( + snap.setPanelKey('subscriptionPlan')}> + View available plans + + } + > + Upgrade your plan to disable the spend cap + + )} + +
+
+ {SPEND_CAP_OPTIONS.map((option) => { + const isSelected = selectedOption === option.value + + return ( +
{ + !isFreePlan && setSelectedOption(option.value) + Telemetry.sendActivity( + { + activity: 'Option Selected', + source: 'Dashboard', + data: { + title: 'Spend cap', + section: 'Cost Control', + option: option.name, + }, + }, + router + ) + }} + > + Spend Cap + +

+ {option.name} +

+
+ ) + })} +
+
+ + {selectedOption === 'on' ? ( + + Exceeding the included quota allowance with spend cap enabled can cause your projects + to become unresponsive or enter read only mode. + + ) : ( + + Your projects will always remain responsive and active, and charges only apply when + exceeding the included quota limit. + + )} + + {hasChanges && ( + <> +

+ {selectedOption === 'on' + ? 'Upon clicking confirm, spend cap will be enabled for your organization and you will no longer be charged any extra for usage.' + : 'Upon clicking confirm, spend cap will be disabled for your organization and you will be charged for any usage beyong the included quota.'} +

+ + )} + + + + ) +} + +export default SpendCapSidePanel diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx index 54c8044cb7e..0d9ffda0a02 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx @@ -73,6 +73,7 @@ const PlanUpdateSidePanel = () => { router ) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]) const onConfirmDowngrade = () => { From 367b9a86b442afdcd7723594424df5f0f00efb7e Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 3 Jul 2023 13:38:53 +0800 Subject: [PATCH 11/21] Small clean up --- .../GeneralSettings/OrganizationBillingMigrationPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/components/interfaces/Organization/GeneralSettings/OrganizationBillingMigrationPanel.tsx b/studio/components/interfaces/Organization/GeneralSettings/OrganizationBillingMigrationPanel.tsx index b59e5280882..48013e406a3 100644 --- a/studio/components/interfaces/Organization/GeneralSettings/OrganizationBillingMigrationPanel.tsx +++ b/studio/components/interfaces/Organization/GeneralSettings/OrganizationBillingMigrationPanel.tsx @@ -1,9 +1,9 @@ import { observer } from 'mobx-react-lite' -import { Alert, Badge, Button, IconExternalLink, IconTruck } from 'ui' +import Link from 'next/link' +import { Badge, Button, IconExternalLink, IconTruck } from 'ui' import Panel from 'components/ui/Panel' import MigrateOrganizationBillingButton from './MigrateOrganizationBillingButton' -import Link from 'next/link' const OrganizationBillingMigrationPanel = observer(() => { return ( From b4a3e48bb977e2221cc168595192fca8cc51e7ee Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 3 Jul 2023 19:18:40 +0800 Subject: [PATCH 12/21] Update settings page to use new scaffold layout component --- .../BillingAddress/BillingAddress.tsx | 249 +++++++++-------- .../BillingSettingsV2/BillingEmail.tsx | 42 ++- .../CostControl/CostControl.tsx | 239 +++++++++------- .../BillingSettingsV2/CreditBalance.tsx | 70 +++-- .../PaymentMethods/PaymentMethods.tsx | 55 ++-- .../Subscription/Subscription.tsx | 63 +++-- .../BillingSettingsV2/TaxID/TaxID.tsx | 263 ++++++++++-------- .../organization-customer-profile-query.ts | 3 +- .../organization-tax-ids-query.ts | 3 +- 9 files changed, 543 insertions(+), 444 deletions(-) diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx index 421c4bb7f3b..d1bf4e11422 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx @@ -3,13 +3,14 @@ import { useEffect } from 'react' import { useParams } from 'common' import { - FormActions, - FormPanel, - FormSection, - FormSectionContent, - FormSectionLabel, -} from 'components/ui/Forms' + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms' import NoPermission from 'components/ui/NoPermission' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation' import { useCheckPermissions, useStore } from 'hooks' @@ -19,7 +20,9 @@ import { COUNTRIES } from './BillingAddress.constants' const BillingAddress = () => { const { ui } = useStore() const { slug } = useParams() - const { data, isLoading, isSuccess } = useOrganizationCustomerProfileQuery({ slug }) + const { data, error, isLoading, isSuccess, isError } = useOrganizationCustomerProfileQuery({ + slug, + }) const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation() const formId = 'billing-address-form' @@ -58,122 +61,138 @@ const BillingAddress = () => { } return ( - -
-

Billing Address

-

This will be reflected in every invoice

-
- - } - > - + + +
+

Billing Address

+

This will be reflected in every invoice

+
+
+ {!canReadBillingAddress ? ( ) : ( -
- {({ isSubmitting, values, initialValues, handleReset, resetForm }: any) => { - const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + <> + {isLoading && ( +
+ + + +
+ )} - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - if (isSuccess && data !== undefined) { - const { city, country, line1, line2, postal_code, state } = data?.address ?? {} - const values = { city, country, line1, line2, postal_code, state } - resetForm({ values, initialValues: values }) - } + {isError && ( + + )} - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSuccess]) + {isSuccess && ( + + {({ isSubmitting, values, initialValues, handleReset, resetForm }: any) => { + const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) - return ( - - - - } - > - - - - -
- - - --- - - {COUNTRIES.map((country) => ( - { + if (isSuccess && data !== undefined) { + const { city, country, line1, line2, postal_code, state } = + data?.address ?? {} + const values = { city, country, line1, line2, postal_code, state } + resetForm({ values, initialValues: values }) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccess]) + + return ( + + +
+ } + > + + + + +
+ - {country.name} - - ))} - - -
-
- - -
-
-
-
- ) - }} - + + --- + + {COUNTRIES.map((country) => ( + + {country.name} + + ))} + + + +
+ + +
+
+
+ + ) + }} + + )} + )} - - + + ) } diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx index 64e90b8a3c5..dbac307c3af 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx @@ -4,12 +4,11 @@ import { useEffect } from 'react' import { useParams } from 'common' import { - FormActions, - FormPanel, - FormSection, - FormSectionContent, - FormSectionLabel, -} from 'components/ui/Forms' + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms' import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' import { patch } from 'lib/common/fetch' @@ -61,22 +60,18 @@ const BillingEmail = () => { } return ( - -
-
-

Email Recipient

-

- All billing correspondence will go to this email -

-
+ + +
+
+

Email Recipient

+

+ All billing correspondence will go to this email +

- - } - > - +
+
+
{({ isSubmitting, handleReset, values, initialValues, resetForm }: any) => { const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) @@ -87,6 +82,7 @@ const BillingEmail = () => { useEffect(() => { const values = { billing_email: billing_email ?? '' } resetForm({ values, initialValues: values }) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [slug]) return ( @@ -122,8 +118,8 @@ const BillingEmail = () => { ) }} - - +
+
) } diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx index 82bc7c6571b..8926d07bde4 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx @@ -1,14 +1,20 @@ import Link from 'next/link' import { useParams, useTheme } from 'common' -import { BASE_PATH } from 'lib/constants' -import { Alert, Button, IconExternalLink } from 'ui' -import SpendCapSidePanel from './SpendCapSidePanel' -import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useFlag } from 'hooks' +import { BASE_PATH } from 'lib/constants' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' +import { Alert, Button, IconExternalLink } from 'ui' import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' +import SpendCapSidePanel from './SpendCapSidePanel' export interface CostControlProps {} @@ -18,7 +24,13 @@ const CostControl = ({}: CostControlProps) => { const snap = useOrgSettingsPageStateSnapshot() const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') - const { data: subscription, isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { + data: subscription, + error, + isLoading, + isSuccess, + isError, + } = useOrgSubscriptionQuery({ orgSlug: slug }) const currentPlan = subscription?.plan const isUsageBillingEnabled = subscription?.usage_billing_enabled ?? false @@ -28,115 +40,126 @@ const CostControl = ({}: CostControlProps) => { return ( <> - -
-
-

Cost Control

-

- Control whether to use beyond your plans included quota -

-
-
-

More information

- -
-
- - } - > - -
- {['team', 'enterprise'].includes(currentPlan?.id || '') ? ( - - {currentPlan?.name || ''} plan requires you to have spend cap off at all times. Your - projects will never become unresponsive. Only when your{' '} - - - included usage - - {' '} - is exceeded will you be charged for any additional usage. - - ) : ( + + +
+
+

Cost Control

- You can control whether your organization is charged for additional usage beyond the{' '} - - - included quota - - {' '} - of your subscription plan. If you need to go beyond the included quota, simply - switch off your spend cap to pay for additional usage. + Control whether to use beyond your plans included quota

- )} - -
+
+
+

More information

-
- Spend Cap -
-
-
-

- Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'} -

-

- {isUsageBillingEnabled ? ( - You will be charged for any usage above the included quota. - ) : ( - - You won't be charged any extra for usage. However, your projects could become - unresponsive or enter read only mode if you exceed the included quota. - - )} -

- {isUsageBillingEnabled && ( -

- Your projects will never become unresponsive. Only when your usage reaches the - quota limit will you be charged for any excess usage. -

- )} - - - + + +
+

Spend cap

+ +
+
+
- - + + + {isLoading && ( +
+ + + +
+ )} + + {isError && } + + {isSuccess && ( +
+ {['team', 'enterprise'].includes(currentPlan?.id || '') ? ( + + {currentPlan?.name || ''} plan requires you to have spend cap off at all times. + Your projects will never become unresponsive. Only when your{' '} + + + included usage + + {' '} + is exceeded will you be charged for any additional usage. + + ) : ( +

+ You can control whether your organization is charged for additional usage beyond + the{' '} + + + included quota + + {' '} + of your subscription plan. If you need to go beyond the included quota, simply + switch off your spend cap to pay for additional usage. +

+ )} + +
+
+
+ Spend Cap +
+
+
+

+ Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'} +

+

+ {isUsageBillingEnabled ? ( + You will be charged for any usage above the included quota. + ) : ( + + You won't be charged any extra for usage. However, your projects could + become unresponsive or enter read only mode if you exceed the included + quota. + + )} +

+ {isUsageBillingEnabled && ( +

+ Your projects will never become unresponsive. Only when your usage reaches the + quota limit will you be charged for any excess usage. +

+ )} + + + +
+
+
+ )} +
+ ) diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx index cdcd469e729..4b0b69c3bbb 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx @@ -1,11 +1,19 @@ import { useParams } from 'common' -import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' import { Badge } from 'ui' const CreditBalance = () => { const { slug } = useParams() - const { data } = useOrganizationCustomerProfileQuery({ slug }) + const { data, error, isLoading, isError, isSuccess } = useOrganizationCustomerProfileQuery({ + slug, + }) const customerBalance = (data?.balance ?? 0) / 100 const isCredit = customerBalance < 0 @@ -16,32 +24,42 @@ const CreditBalance = () => { : customerBalance.toFixed(2) return ( - -
-
-

Credit balance

- {isCredit && You have credits available} - {isDebt && Outstanding payments} -
-

- Charges will be deducted from your balance first -

+ + +
+
+

Credit balance

+ {isCredit && You have credits available} + {isDebt && Outstanding payments}
- - } - > - -
- {isDebt &&

-

} -

$

-

{balance}

- {isCredit &&

/credits

} +

+ Charges will be deducted from your balance first +

-
- + + + {isLoading && ( +
+ + + +
+ )} + + {isError && ( + + )} + + {isSuccess && ( +
+ {isDebt &&

-

} +

$

+

{balance}

+ {isCredit &&

/credits

} +
+ )} +
+ ) } diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx index 18ef727fa21..04b69f49827 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx @@ -4,8 +4,15 @@ import { useState } from 'react' import { useParams } from 'common' import { AddNewPaymentMethodModal } from 'components/interfaces/Billing' -import { FormPanel, FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms' import NoPermission from 'components/ui/NoPermission' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { organizationKeys } from 'data/organizations/keys' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' @@ -15,7 +22,6 @@ import { getURL } from 'lib/helpers' import { Badge, Button, Dropdown, IconCreditCard, IconMoreHorizontal, IconPlus } from 'ui' import ChangePaymentMethodModal from './ChangePaymentMethodModal' import DeletePaymentMethodModal from './DeletePaymentMethodModal' -import AlertError from 'components/ui/AlertError' const PaymentMethods = () => { const { ui } = useStore() @@ -47,31 +53,36 @@ const PaymentMethods = () => { return ( <> - -
-
-

Payment methods

-

- This copy might need to change, when adding a new payment method, either remove - the old one or go to your projects' subscription to explicitly update the payment - method. -

-
+ + +
+
+

Payment methods

+

+ This copy might need to change, when adding a new payment method, either remove the + old one or go to your projects' subscription to explicitly update the payment + method. +

- - } - > - +
+
+ {!canReadPaymentMethods ? ( ) : ( <> + {isLoading && ( +
+ + + +
+ )} + {isError && ( )} + {isSuccess && ( { Expires: {paymentMethod.card.exp_month}/ {paymentMethod.card.exp_year}

- {isActive && Active}
+ {isActive && Active} {canUpdatePaymentMethods && !isActive ? ( { )} )} - -
+ + { const { slug } = useParams() @@ -38,30 +43,34 @@ const Subscription = () => { return ( <> - -
-

Subscription Plan

-
-

More information

- + + +
+

Subscription Plan

+
+

More information

+
- - } - > - +
+
+ + {isLoading && ( +
+ + + +
+ )} + {isError && } {isSuccess && ( @@ -169,8 +178,8 @@ const Subscription = () => { )}
)} - - + + ) diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx index 28b3957a9fc..c0c81f6d1c1 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx @@ -1,31 +1,38 @@ -import { useEffect, useState } from 'react' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect, useState } from 'react' -import { - FormActions, - FormPanel, - FormSection, - FormSectionContent, - FormSectionLabel, -} from 'components/ui/Forms' -import NoPermission from 'components/ui/NoPermission' -import { useCheckPermissions, useStore } from 'hooks' -import { Button, Form, IconPlus, IconX, Input, Listbox } from 'ui' -import { TAX_IDS } from './TaxID.constants' -import { TaxId, useOrganizationTaxIDsQuery } from 'data/organizations/organization-tax-ids-query' import { useParams } from 'common' -import { uuidv4 } from 'lib/helpers' -import { isEqual } from 'lodash' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { TaxId, useOrganizationTaxIDsQuery } from 'data/organizations/organization-tax-ids-query' import { TaxIdValue, useOrganizationTaxIDsUpdateMutation, } from 'data/organizations/organization-tax-ids-update-mutation' +import { useCheckPermissions, useStore } from 'hooks' +import { uuidv4 } from 'lib/helpers' +import { isEqual } from 'lodash' +import { Button, Form, IconPlus, IconX, Input, Listbox } from 'ui' +import { TAX_IDS } from './TaxID.constants' import { sanitizeTaxID } from './TaxID.utilts' const TaxID = () => { const { ui } = useStore() const { slug } = useParams() - const { data: taxIds, isLoading, isSuccess, isError } = useOrganizationTaxIDsQuery({ slug }) + const { + data: taxIds, + error, + isLoading, + isSuccess, + isError, + } = useOrganizationTaxIDsQuery({ slug }) const { mutateAsync: updateTaxIDs } = useOrganizationTaxIDsUpdateMutation() const [errors, setErrors] = useState([]) @@ -116,117 +123,131 @@ const TaxID = () => { } return ( - -
-
-

Tax ID

-

- If you would like to include specific tax ID(s) to your invoices. -

-

- Make sure the tax ID looks exactly like the placeholder text. -

-
+ + +
+
+

Tax ID

+

+ If you would like to include specific tax ID(s) to your invoices. +

+

+ Make sure the tax ID looks exactly like the placeholder text. +

- - } - > - +
+
+ {!canReadTaxIds ? ( ) : ( -
- {({ isSubmitting }: { isSubmitting: boolean }) => ( - - onResetTaxIds()} - helper={ - !canUpdateTaxIds - ? "You need additional permissions to manage this organization's tax IDs" - : undefined - } - /> -
- } - > - - - {taxIdValues.length >= 1 ? ( -
- {taxIdValues.map((taxId, idx: number) => { - const selectedTaxId = TAX_IDS.find((option) => option.code === taxId.type) - return ( -
- onUpdateTaxId(taxId.id, 'name', e)} - disabled={!canUpdateTaxIds} - className="w-[200px]" - > - {TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map( - (option) => ( - - {option.country} - {option.name} - - ) - )} - - - onUpdateTaxId(taxId.id, 'value', e.target.value) - } - disabled={!canUpdateTaxIds} - /> - {canUpdateTaxIds && ( -
- ) - })} -
- ) : ( -
-

- No tax IDs -

-
- )} - {canUpdateTaxIds && ( -
onAddNewTaxId()} - > - -

Add another ID

-
- )} -
-
- + <> + {isLoading && ( +
+ + + +
)} - + + {isError && ( + + )} + + {isSuccess && ( +
+ {({ isSubmitting }: { isSubmitting: boolean }) => ( + + onResetTaxIds()} + helper={ + !canUpdateTaxIds + ? "You need additional permissions to manage this organization's tax IDs" + : undefined + } + /> +
+ } + > + + + {taxIdValues.length >= 1 ? ( +
+ {taxIdValues.map((taxId, idx: number) => { + const selectedTaxId = TAX_IDS.find( + (option) => option.code === taxId.type + ) + return ( +
+ onUpdateTaxId(taxId.id, 'name', e)} + disabled={!canUpdateTaxIds} + className="w-[200px]" + > + {TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map( + (option) => ( + + {option.country} - {option.name} + + ) + )} + + + onUpdateTaxId(taxId.id, 'value', e.target.value) + } + disabled={!canUpdateTaxIds} + /> + {canUpdateTaxIds && ( +
+ ) + })} +
+ ) : ( +
+

+ No tax IDs +

+
+ )} + {canUpdateTaxIds && ( +
onAddNewTaxId()} + > + +

Add another ID

+
+ )} +
+
+ + )} + + )} + )} - -
+ + ) } diff --git a/studio/data/organizations/organization-customer-profile-query.ts b/studio/data/organizations/organization-customer-profile-query.ts index de495773339..3859a64c620 100644 --- a/studio/data/organizations/organization-customer-profile-query.ts +++ b/studio/data/organizations/organization-customer-profile-query.ts @@ -3,6 +3,7 @@ import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { useCallback } from 'react' import { organizationKeys } from './keys' +import { ResponseError } from 'types' export type OrganizationCustomerProfileVariables = { slug?: string @@ -40,7 +41,7 @@ export async function getOrganizationCustomerProfile( export type OrganizationCustomerProfileData = Awaited< ReturnType > -export type OrganizationCustomerProfileError = unknown +export type OrganizationCustomerProfileError = ResponseError export const useOrganizationCustomerProfileQuery = ( { slug }: OrganizationCustomerProfileVariables, diff --git a/studio/data/organizations/organization-tax-ids-query.ts b/studio/data/organizations/organization-tax-ids-query.ts index 059840795fd..2a6c7589415 100644 --- a/studio/data/organizations/organization-tax-ids-query.ts +++ b/studio/data/organizations/organization-tax-ids-query.ts @@ -3,6 +3,7 @@ import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { useCallback } from 'react' import { organizationKeys } from './keys' +import { ResponseError } from 'types' export type OrganizationTaxIDsVariables = { slug?: string @@ -37,7 +38,7 @@ export async function getOrganizationTaxIDs( } export type OrganizationTaxIDsData = Awaited> -export type OrganizationTaxIDsError = unknown +export type OrganizationTaxIDsError = ResponseError export const useOrganizationTaxIDsQuery = ( { slug }: OrganizationTaxIDsVariables, From fa740d2118f9ae2bed5b30a75b22f3ef73edf0e1 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 4 Jul 2023 14:27:34 +0800 Subject: [PATCH 13/21] Midway updating usage --- .../interfaces/Organization/Usage/Usage.tsx | 58 ++++++++++--------- studio/pages/org/[slug]/usage.tsx | 50 ++-------------- 2 files changed, 36 insertions(+), 72 deletions(-) diff --git a/studio/components/interfaces/Organization/Usage/Usage.tsx b/studio/components/interfaces/Organization/Usage/Usage.tsx index e6123655fd5..5709377b625 100644 --- a/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -10,6 +10,7 @@ import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants' import SizeAndCounts from './SizeAndCounts' import Activity from './Activity' import { IconLoader } from 'ui' +import { ScaffoldContainer, ScaffoldDivider, ScaffoldSection } from 'components/layouts/Scaffold' const Usage = () => { const { slug } = useParams() @@ -65,33 +66,38 @@ const Usage = () => { return ( <> -
- {!isLoadingSubscription && ( - - )} + +
+ {!isLoadingSubscription && ( + + )} + + {isLoadingSubscription ? ( + + ) : subscription !== undefined ? ( +
+

+ Organization is on the {subscription.plan.name} plan +

+

+ {billingCycleStart.format('DD MMM YYYY')} - {billingCycleEnd.format('DD MMM YYYY')} +

+
+ ) : null} +
+
+ + - {isLoadingSubscription ? ( - - ) : subscription !== undefined ? ( -
-

- Organization is on the {subscription.plan.name} plan -

-

- {billingCycleStart.format('DD MMM YYYY')} - {billingCycleEnd.format('DD MMM YYYY')} -

-
- ) : null} -
{ - const { data: permissions } = usePermissionsQuery() + const { isLoading } = usePermissionsQuery() const selectedOrganization = useSelectedOrganization() - const { slug } = useParams() - const router = useRouter() - const showOAuthApps = useFlag('oauthApps') - return ( - <> - {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( - - ) : ( -
-
-
-

{selectedOrganization?.name ?? 'Organization'} settings

-
- -
- -
- -
-
- )} - - ) + return <>{selectedOrganization === undefined && isLoading ? : } } OrgUsage.getLayout = (page) => {page} From a7e4784cadc1fceaed9927949e8a37cda9fb68a6 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 4 Jul 2023 15:04:17 +0800 Subject: [PATCH 14/21] Fix usage --- .../Organization/Usage/SectionContent.tsx | 76 ++++++++++--------- .../Organization/Usage/SectionHeader.tsx | 10 +-- .../interfaces/Organization/Usage/Usage.tsx | 3 +- .../Organization/Usage/UsageSection.tsx | 7 +- 4 files changed, 53 insertions(+), 43 deletions(-) diff --git a/studio/components/interfaces/Organization/Usage/SectionContent.tsx b/studio/components/interfaces/Organization/Usage/SectionContent.tsx index 1d2d8f269e9..a316df9e863 100644 --- a/studio/components/interfaces/Organization/Usage/SectionContent.tsx +++ b/studio/components/interfaces/Organization/Usage/SectionContent.tsx @@ -2,6 +2,7 @@ import { PropsWithChildren } from 'react' import { Badge, IconExternalLink } from 'ui' import { CategoryAttribute } from './Usage.constants' import Link from 'next/link' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' export interface SectionContent { section: CategoryAttribute @@ -16,47 +17,52 @@ const SectionContent = ({ const { name, description, links } = section return ( -
-
-
-
-
-
-
-

{name}

- {includedInPlan === false && Not included} + <> + +
+
+
+
+
+
+

{name}

+ {includedInPlan === false && Not included} +
+
+ {description.split('\n').map((value, idx) => ( +

+ {value} +

+ ))} +
-
- {description.split('\n').map((value, idx) => ( -

- {value} + {links && links.length && ( +

+

+ More information

- ))} -
+ {links.map((link) => ( + + ))} +
+ )}
- {links && links.length && ( -
-

More information

- {links.map((link) => ( - - ))} -
- )}
+
{children}
-
{children}
-
-
+ + + ) } diff --git a/studio/components/interfaces/Organization/Usage/SectionHeader.tsx b/studio/components/interfaces/Organization/Usage/SectionHeader.tsx index 6b2b35532b1..3f453ece07d 100644 --- a/studio/components/interfaces/Organization/Usage/SectionHeader.tsx +++ b/studio/components/interfaces/Organization/Usage/SectionHeader.tsx @@ -5,12 +5,10 @@ export interface SectionHeaderProps { const SectionHeader = ({ title, description }: SectionHeaderProps) => { return ( -
-
-
-

{title}

-

{description}

-
+
+
+

{title}

+

{description}

) diff --git a/studio/components/interfaces/Organization/Usage/Usage.tsx b/studio/components/interfaces/Organization/Usage/Usage.tsx index 5709377b625..087e221556c 100644 --- a/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -67,7 +67,7 @@ const Usage = () => { return ( <> -
+
{!isLoadingSubscription && ( { endDate={endDate} currentBillingCycleSelected={currentBillingCycleSelected} /> + - + + + + + {categoryMeta.attributes.map((attribute) => { const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse] as UsageMetric From 0d5430edb1a224bec07aee3f9d2d241088ef2d64 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 4 Jul 2023 15:21:42 +0800 Subject: [PATCH 15/21] Fixes --- .../ChangePaymentMethodModal.tsx | 2 +- .../Subscription/Subscription.tsx | 5 - .../SubscriptionPaymentMethod.tsx | 216 ------------------ 3 files changed, 1 insertion(+), 222 deletions(-) delete mode 100644 studio/components/interfaces/Organization/BillingSettingsV2/Subscription/SubscriptionPaymentMethod.tsx diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx index 3ad2afd3d22..8abb06b9d87 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx @@ -47,7 +47,7 @@ const ChangePaymentMethodModal = ({ } catch (error: any) { ui.setNotification({ category: 'error', - message: `Failed to delete payment method: ${error.message}`, + message: `Failed to change payment method: ${error.message}`, }) } } diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx index 7f46a9482c3..8051efb8697 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx @@ -15,7 +15,6 @@ import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Alert, Button, IconExternalLink } from 'ui' import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' import PlanUpdateSidePanel from './PlanUpdateSidePanel' -import SubscriptionPaymentMethod from './SubscriptionPaymentMethod' import ShimmeringLoader from 'components/ui/ShimmeringLoader' const Subscription = () => { @@ -172,10 +171,6 @@ const Subscription = () => { labelBottomClass="!text-scale-1000 pb-1" labelTop={`${daysToCycleEnd} Days left`} /> - - {subscription !== undefined && ( - - )}
)} diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/SubscriptionPaymentMethod.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/SubscriptionPaymentMethod.tsx deleted file mode 100644 index ef1d67e8d25..00000000000 --- a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/SubscriptionPaymentMethod.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import * as Tooltip from '@radix-ui/react-tooltip' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useState } from 'react' - -import { useParams } from 'common' -import Panel from 'components/ui/Panel' -import { - SubscriptionTier, - updateSubscriptionTier, -} from 'data/subscriptions/project-subscription-update-mutation' -import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' -import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' -import { BASE_PATH } from 'lib/constants' -import Link from 'next/link' -import { Button, IconCreditCard, Modal } from 'ui' -import PaymentMethodSelection from './PaymentMethodSelection' -import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' - -export interface SubscriptionTierProps { - subscription: ProjectSubscriptionResponse -} - -const SubscriptionPaymentMethod = ({ subscription }: SubscriptionTierProps) => { - const { ui } = useStore() - const { slug } = useParams() - - const selectedOrganization = useSelectedOrganization() - const currentOrgSlug = selectedOrganization?.slug - - const { mutateAsync: updateOrgSubscription } = useOrgSubscriptionUpdateMutation() - - const canUpdatePaymentMethod = useCheckPermissions( - PermissionAction.BILLING_WRITE, - 'stripe.subscriptions' - ) - - const [isSubmitting, setIsSubmitting] = useState(false) - const [showPaymentEditModal, setShowPaymentEditModal] = useState(false) - const [selectedPaymentMethod, setSelectedPaymentMethod] = useState( - subscription.payment_method_id || '' - ) - - const onConfirmPaymentMethod = async () => { - setIsSubmitting(true) - - if (selectedPaymentMethod) { - if (!slug) return console.error('Slug is required') - if (!selectedPaymentMethod) { - return ui.setNotification({ category: 'error', message: 'Please select a payment method' }) - } - - const selectedTier = - subscription.plan.id === 'pro' && subscription.usage_billing_enabled - ? 'tier_payg' - : `tier_${subscription.plan.id}` - - try { - setIsSubmitting(true) - await updateOrgSubscription({ - slug, - tier: selectedTier as SubscriptionTier, - paymentMethod: selectedPaymentMethod, - }) - setShowPaymentEditModal(false) - ui.setNotification({ category: 'success', message: `Successfully updated payment method!` }) - } catch (error: any) { - ui.setNotification({ - error, - category: 'error', - message: `Unable to update subscription: ${error.message}`, - }) - } finally { - setIsSubmitting(false) - } - } - - setIsSubmitting(false) - } - - if (subscription.payment_method_type === 'none') return null - - return ( - <> - - - {subscription.payment_method_type === 'card' && ( - <> -
- {subscription.payment_method_card_details?.brand ? ( - Credit card brand - ) : ( - - )} - - **** **** ****{' '} - {subscription?.payment_method_card_details?.last_4_digits || '****'} - -
-
- - Expires {subscription?.payment_method_card_details?.expiry_month || '-'}/ - {subscription?.payment_method_card_details?.expiry_year?.toString()?.slice(-2) || - '-'} - -
- - )} - - {subscription?.payment_method_type === 'invoice' && ( - <> -
-
-

Payment via invoice

-

- You get a monthly invoice and payment link via email. -

-
-
- - )} - - - -
- -
-
- {subscription?.payment_method_type !== 'card' ? ( - - - -
- - Please email support@supbase.io to change your payment method. - -
-
-
- ) : !canUpdatePaymentMethod ? ( - - - -
- - You do not have permission to change the payment method - -
-
-
- ) : null} -
-
-
- - setShowPaymentEditModal(false)} - onConfirm={onConfirmPaymentMethod} - overlayClassName="pointer-events-none" - header={`Change payment method`} - > - -
-

- Upon clicking confirm, all future charges will be deducted from the selected payment - method. There are no immediate charges. Changing the payment method for this project - does not affect the payment method for other projects. -

-

- To remove unused or expired payment methods, head to your{' '} - - - organization's billing settings - - - . -

-
- -
-
-
-
- - ) -} - -export default SubscriptionPaymentMethod From b2301aada1e87d88e469f94e22913660b8e33492 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 5 Jul 2023 11:03:00 +0800 Subject: [PATCH 16/21] Address comments --- .../data/organizations/organization-tax-ids-query.ts | 1 + .../subscriptions/org-subscription-update-mutation.ts | 11 ++++------- studio/types/base.ts | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/studio/data/organizations/organization-tax-ids-query.ts b/studio/data/organizations/organization-tax-ids-query.ts index 2a6c7589415..355bb50770a 100644 --- a/studio/data/organizations/organization-tax-ids-query.ts +++ b/studio/data/organizations/organization-tax-ids-query.ts @@ -65,5 +65,6 @@ export const useOrganizationTaxIDsPrefetch = ({ slug }: OrganizationTaxIDsVariab getOrganizationTaxIDs({ slug }, signal) ) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [slug]) } diff --git a/studio/data/subscriptions/org-subscription-update-mutation.ts b/studio/data/subscriptions/org-subscription-update-mutation.ts index f61c9ec923e..99f7ed989c4 100644 --- a/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -2,6 +2,7 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react import { put } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { subscriptionKeys } from './keys' +import { SupaResponse } from 'types/base' export type SubscriptionTier = | 'tier_free' @@ -16,15 +17,11 @@ export type OrgSubscriptionUpdateVariables = { tier: SubscriptionTier } -export type OrgSubscriptionUpdateResponse = { - error?: any -} - -export async function updateOrgSubscription({ +export async function updateOrgSubscription({ slug, tier, paymentMethod, -}: OrgSubscriptionUpdateVariables) { +}: OrgSubscriptionUpdateVariables): Promise> { if (!slug) throw new Error('slug is required') if (!tier) throw new Error('tier is required') @@ -34,7 +31,7 @@ export async function updateOrgSubscription({ const response = await put(`${API_URL}/organizations/${slug}/billing/subscription`, payload) if (response.error) throw response.error - return response as OrgSubscriptionUpdateResponse + return response as SupaResponse } type OrgSubscriptionUpdateData = Awaited> diff --git a/studio/types/base.ts b/studio/types/base.ts index 0a79e4f9dc6..eb61191087d 100644 --- a/studio/types/base.ts +++ b/studio/types/base.ts @@ -83,7 +83,7 @@ export interface ResponseFailure { error: ResponseError } -export type SupaResponse = T & ResponseFailure +export type SupaResponse = T | ResponseFailure export interface ResponseError { code: number From 9e67f2b86bb7870d3212b0a77c7b75fcc451b6fc Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 5 Jul 2023 11:24:38 +0800 Subject: [PATCH 17/21] fix --- .../org-subscription-update-mutation.ts | 16 +++++++++------- studio/lib/integration-utils.ts | 6 ++---- studio/types/base.ts | 5 ++++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/studio/data/subscriptions/org-subscription-update-mutation.ts b/studio/data/subscriptions/org-subscription-update-mutation.ts index 99f7ed989c4..210f85bdf2f 100644 --- a/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -2,7 +2,7 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react import { put } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { subscriptionKeys } from './keys' -import { SupaResponse } from 'types/base' +import { SupaResponseV2 } from 'types/base' export type SubscriptionTier = | 'tier_free' @@ -17,21 +17,23 @@ export type OrgSubscriptionUpdateVariables = { tier: SubscriptionTier } -export async function updateOrgSubscription({ +export async function updateOrgSubscription({ slug, tier, paymentMethod, -}: OrgSubscriptionUpdateVariables): Promise> { +}: OrgSubscriptionUpdateVariables) { if (!slug) throw new Error('slug is required') if (!tier) throw new Error('tier is required') const payload: { tier: string; payment_method?: string } = { tier } if (paymentMethod !== undefined) payload.payment_method = paymentMethod - const response = await put(`${API_URL}/organizations/${slug}/billing/subscription`, payload) - if (response.error) throw response.error - - return response as SupaResponse + const response = (await put( + `${API_URL}/organizations/${slug}/billing/subscription`, + payload + )) as SupaResponseV2 + if (typeof response === 'object' && response !== null && 'error' in response) throw response.error + return response } type OrgSubscriptionUpdateData = Awaited> diff --git a/studio/lib/integration-utils.ts b/studio/lib/integration-utils.ts index 63f9a93969f..da206ee5f29 100644 --- a/studio/lib/integration-utils.ts +++ b/studio/lib/integration-utils.ts @@ -1,8 +1,6 @@ -import { ResponseFailure } from 'types' +import { SupaResponseV2 } from 'types' -type SupaResponse = T | ResponseFailure - -async function fetchGitHub(url: string, responseJson = true): Promise> { +async function fetchGitHub(url: string, responseJson = true): Promise> { const response = await fetch(url) if (!response.ok) { return { diff --git a/studio/types/base.ts b/studio/types/base.ts index eb61191087d..53bef164213 100644 --- a/studio/types/base.ts +++ b/studio/types/base.ts @@ -83,7 +83,10 @@ export interface ResponseFailure { error: ResponseError } -export type SupaResponse = T | ResponseFailure +// [Joshen] This should ideally be | but not changing now as its gonna cause a lot of build errors +export type SupaResponse = T & ResponseFailure +// [Joshen] Use this for now, eventually deprecate SupaResponse above +export type SupaResponseV2 = T | ResponseFailure export interface ResponseError { code: number From 3d2c38c18430e1fb7d4358faa9c9d6ace0c88746 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 5 Jul 2023 11:29:12 +0800 Subject: [PATCH 18/21] fix --- .../data/subscriptions/org-subscription-update-mutation.ts | 4 ++-- studio/lib/integration-utils.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/studio/data/subscriptions/org-subscription-update-mutation.ts b/studio/data/subscriptions/org-subscription-update-mutation.ts index 210f85bdf2f..a613486f2c3 100644 --- a/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -17,7 +17,7 @@ export type OrgSubscriptionUpdateVariables = { tier: SubscriptionTier } -export async function updateOrgSubscription({ +export async function updateOrgSubscription({ slug, tier, paymentMethod, @@ -31,7 +31,7 @@ export async function updateOrgSubscription({ const response = (await put( `${API_URL}/organizations/${slug}/billing/subscription`, payload - )) as SupaResponseV2 + )) as SupaResponseV2 if (typeof response === 'object' && response !== null && 'error' in response) throw response.error return response } diff --git a/studio/lib/integration-utils.ts b/studio/lib/integration-utils.ts index da206ee5f29..141687061c8 100644 --- a/studio/lib/integration-utils.ts +++ b/studio/lib/integration-utils.ts @@ -1,6 +1,4 @@ -import { SupaResponseV2 } from 'types' - -async function fetchGitHub(url: string, responseJson = true): Promise> { +async function fetchGitHub(url: string, responseJson = true) { const response = await fetch(url) if (!response.ok) { return { @@ -12,7 +10,7 @@ async function fetchGitHub(url: string, responseJson = true): Promise Date: Wed, 5 Jul 2023 11:30:24 +0800 Subject: [PATCH 19/21] fix --- studio/lib/integration-utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/studio/lib/integration-utils.ts b/studio/lib/integration-utils.ts index 141687061c8..c0453e7e1b1 100644 --- a/studio/lib/integration-utils.ts +++ b/studio/lib/integration-utils.ts @@ -1,4 +1,6 @@ -async function fetchGitHub(url: string, responseJson = true) { +import { SupaResponseV2 } from 'types' + +async function fetchGitHub(url: string, responseJson = true): Promise> { const response = await fetch(url) if (!response.ok) { return { @@ -10,7 +12,7 @@ async function fetchGitHub(url: string, responseJson = true) { } } try { - return responseJson ? await response.json() : await response.text() + return (responseJson ? await response.json() : await response.text()) as T } catch (error: any) { return { error: { From 3ef41258b416953d3fcf3d51654d48a3a99737b4 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 5 Jul 2023 11:32:37 +0800 Subject: [PATCH 20/21] fix --- studio/lib/integration-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/lib/integration-utils.ts b/studio/lib/integration-utils.ts index c0453e7e1b1..da206ee5f29 100644 --- a/studio/lib/integration-utils.ts +++ b/studio/lib/integration-utils.ts @@ -1,6 +1,6 @@ import { SupaResponseV2 } from 'types' -async function fetchGitHub(url: string, responseJson = true): Promise> { +async function fetchGitHub(url: string, responseJson = true): Promise> { const response = await fetch(url) if (!response.ok) { return { From 2402245d254ddabd51808d6499e4b728d739a83a Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 5 Jul 2023 11:33:04 +0800 Subject: [PATCH 21/21] fix --- studio/data/subscriptions/org-subscription-update-mutation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/data/subscriptions/org-subscription-update-mutation.ts b/studio/data/subscriptions/org-subscription-update-mutation.ts index a613486f2c3..d2592bbcc8f 100644 --- a/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -17,7 +17,7 @@ export type OrgSubscriptionUpdateVariables = { tier: SubscriptionTier } -export async function updateOrgSubscription({ +export async function updateOrgSubscription({ slug, tier, paymentMethod,