From 4d4036e64914e4fc30acd252f90e88c02f9b7d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Wed, 18 Jun 2025 14:22:08 +0800 Subject: [PATCH 1/6] chore: update code owners or pricing (#36499) Allow billing team to approve/make changes --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 436f6aeeff6dc..f7ea12c26a599 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,6 @@ /packages/ui/ @supabase/design -/packages/shared-data/pricing.ts @roryw10 @kevcodez -/packages/shared-data/plans.ts @roryw10 @kevcodez +/packages/shared-data/pricing.ts @roryw10 @supabase/billing +/packages/shared-data/plans.ts @roryw10 @supabase/billing /packages/common/telemetry-constants.ts @4L3k51 @supabase/growth-eng /apps/studio/ @supabase/Dashboard From 7d9bdb3abc04c9e820bdd73847ab7fd4c8888347 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 18 Jun 2025 14:28:43 +0800 Subject: [PATCH 2/6] chore: add planId (#36500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kevin Grüneberg --- packages/shared-data/plans.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/shared-data/plans.ts b/packages/shared-data/plans.ts index 9ed27213f6287..d0b7d9d61ec4d 100644 --- a/packages/shared-data/plans.ts +++ b/packages/shared-data/plans.ts @@ -1,5 +1,8 @@ +export type PlanId = 'free' | 'pro' | 'team' | 'enterprise' + export interface PricingInformation { id: string + planId: PlanId name: string nameBadge?: string costUnit?: string @@ -18,6 +21,7 @@ export interface PricingInformation { export const plans: PricingInformation[] = [ { id: 'tier_free', + planId: 'free', name: 'Free', nameBadge: '', costUnit: '/ month', @@ -63,6 +67,7 @@ export const plans: PricingInformation[] = [ }, { id: 'tier_pro', + planId: 'pro', name: 'Pro', nameBadge: 'Most Popular', costUnit: '/ month', @@ -100,6 +105,7 @@ export const plans: PricingInformation[] = [ }, { id: 'tier_team', + planId: 'team', name: 'Team', nameBadge: '', costUnit: '/ month', @@ -127,6 +133,7 @@ export const plans: PricingInformation[] = [ }, { id: 'tier_enterprise', + planId: 'enterprise', name: 'Enterprise', href: 'https://forms.supabase.com/enterprise', description: 'For large-scale applications running Internet scale workloads.', @@ -148,7 +155,7 @@ export const plans: PricingInformation[] = [ preface: '', cta: 'Contact Us', }, -] +] as const export function pickFeatures(plan: PricingInformation, billingPartner: string = '') { return ( From dff6074d3076716b80977f130cfdb14c9e30390e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Wed, 18 Jun 2025 15:11:33 +0800 Subject: [PATCH 3/6] feat: support pending changes for plan upgrades (#36430) This PR implements the new flow to confirm subscription upgrades using Orb pending changes. This is backwards compatible and based on a flag exposed by the backend (`subscriptionPreview.pending_subscription_flow`). Just like the organization creation, the entire flow is slightly different - instead of creating a payment method separately, the payment method is added inline while doing the upgrade and then attached to the customer. If payment fails, the upgrade will not go through. If payment requires additional action, the user needs to confirm the payment before allowing the upgrade. For testing the new flow locally, toggle the flag in `flags.ts` on the backend. Changes include - No longer rely on the `changeType` from the plans endpoint as this is regularly out-of-sync and displays wrong up/downgrade info due to race conditions - `readOnly` mode for Stripe elements if anything is loading/submitting - Reduced prop drilling for some components - Hide payment method and address selection on downgrade --- .../Subscription/Subscription.utils.ts | 42 +++- .../BillingSettings/CreditTopUp.tsx | 2 + .../NewPaymentMethodElement.tsx | 5 +- .../Subscription/DowngradeModal.tsx | 6 +- .../Subscription/PaymentMethodSelection.tsx | 194 ++++++++++++++- .../Subscription/PlanUpdateSidePanel.tsx | 11 +- .../SubscriptionPlanUpdateDialog.tsx | 232 +++++++++++++----- .../Organization/NewOrg/NewOrgForm.tsx | 11 +- ...ganization-billing-subscription-preview.ts | 1 + ...org-subscription-confirm-pending-change.ts | 76 +++--- ...org-subscription-confirm-pending-create.ts | 95 +++++++ .../org-subscription-update-mutation.ts | 29 ++- packages/api-types/types/api.d.ts | 12 +- packages/api-types/types/platform.d.ts | 64 ++++- 14 files changed, 626 insertions(+), 154 deletions(-) create mode 100644 apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts diff --git a/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts b/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts index 2d8af813fde05..ece737507d6a4 100644 --- a/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts +++ b/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts @@ -1,4 +1,4 @@ -import type { OrgSubscription, ProjectSelectedAddon } from 'data/subscriptions/types' +import type { OrgSubscription, PlanId, ProjectSelectedAddon } from 'data/subscriptions/types' import { IS_PLATFORM } from 'lib/constants' export const getAddons = (selectedAddons: ProjectSelectedAddon[]) => { @@ -32,3 +32,43 @@ export const billingPartnerLabel = (billingPartner?: string) => { return billingPartner } } + +type PlanChangeType = 'upgrade' | 'downgrade' | 'none' + +export const getPlanChangeType = ( + fromPlan: PlanId | undefined, + toPlan: PlanId | undefined +): PlanChangeType => { + const planChangeTypes: Record> = { + free: { + free: 'none', + pro: 'upgrade', + team: 'upgrade', + enterprise: 'upgrade', + }, + pro: { + free: 'downgrade', + pro: 'none', + team: 'upgrade', + enterprise: 'upgrade', + }, + team: { + free: 'downgrade', + pro: 'downgrade', + team: 'none', + enterprise: 'upgrade', + }, + enterprise: { + free: 'downgrade', + pro: 'downgrade', + team: 'downgrade', + enterprise: 'none', + }, + } + + if (!fromPlan || !toPlan) { + return 'none' + } + + return planChangeTypes[fromPlan]?.[toPlan] ?? 'none' +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index c3edcba0847f3..a2e71206e62ac 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -252,8 +252,10 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { name="paymentMethod" render={() => ( form.setValue('paymentMethod', pm)} selectedPaymentMethod={form.getValues('paymentMethod')} + readOnly={executingTopUp || paymentConfirmationLoading} /> )} /> diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx index 0578878093a8d..5123b3bca2c2e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx @@ -15,7 +15,8 @@ const NewPaymentMethodElement = forwardRef( { pending_subscription_flow_enabled, email, - }: { pending_subscription_flow_enabled: boolean; email?: string }, + readOnly, + }: { pending_subscription_flow_enabled: boolean; email?: string; readOnly: boolean }, ref ) => { const stripe = useStripe() @@ -58,7 +59,7 @@ const NewPaymentMethodElement = forwardRef( createPaymentMethod, })) - return + return } ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx index 94e7f2f1bdbee..91a3e3ceb7c2c 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx @@ -5,10 +5,11 @@ import type { OrgSubscription, ProjectAddon } from 'data/subscriptions/types' import { PricingInformation } from 'shared-data' import { Modal } from 'ui' import { Admonition } from 'ui-patterns' +import { plans as subscriptionsPlans } from 'shared-data/plans' +import { useMemo } from 'react' export interface DowngradeModalProps { visible: boolean - selectedPlan?: PricingInformation subscription?: OrgSubscription onClose: () => void onConfirm: () => void @@ -50,12 +51,13 @@ const ProjectDowngradeListItem = ({ projectAddon }: { projectAddon: ProjectAddon const DowngradeModal = ({ visible, - selectedPlan, subscription, onClose, onConfirm, projects, }: DowngradeModalProps) => { + const selectedPlan = useMemo(() => subscriptionsPlans.find((tier) => tier.id === 'tier_free'), []) + // Filter out the micro addon as we're dealing with that separately const previousProjectAddons = subscription?.project_addons.flatMap((projectAddons) => { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 0555c36687d97..85441004f9574 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -1,5 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useEffect, useState } from 'react' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import { toast } from 'sonner' import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal' @@ -7,25 +15,51 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { BASE_PATH } from 'lib/constants' +import { BASE_PATH, STRIPE_PUBLIC_KEY } from 'lib/constants' import { getURL } from 'lib/helpers' import { AlertCircle, CreditCard, Loader, Plus } from 'lucide-react' import { Listbox } from 'ui' +import HCaptcha from '@hcaptcha/react-hcaptcha' +import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' +import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation' +import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation' +import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' +import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' +import { useTheme } from 'next-themes' +import { Elements } from '@stripe/react-stripe-js' +import { NewPaymentMethodElement } from '../PaymentMethods/NewPaymentMethodElement' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' + +const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) export interface PaymentMethodSelectionProps { selectedPaymentMethod?: string onSelectPaymentMethod: (id: string) => void layout?: 'vertical' | 'horizontal' + createPaymentMethodInline: boolean + readOnly: boolean } -const PaymentMethodSelection = ({ - selectedPaymentMethod, - onSelectPaymentMethod, - layout = 'vertical', -}: PaymentMethodSelectionProps) => { +const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( + { + selectedPaymentMethod, + onSelectPaymentMethod, + layout = 'vertical', + createPaymentMethodInline = false, + readOnly, + }: PaymentMethodSelectionProps, + ref +) { const selectedOrganization = useSelectedOrganization() const slug = selectedOrganization?.slug const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false) + const captchaLoaded = useIsHCaptchaLoaded() + const [captchaToken, setCaptchaToken] = useState(null) + const [captchaRef, setCaptchaRef] = useState(null) + const [setupIntent, setSetupIntent] = useState(undefined) + const { resolvedTheme } = useTheme() + const paymentRef = useRef<{ createPaymentMethod: () => Promise }>(null) + const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState(null) const { data: paymentMethods, @@ -33,11 +67,77 @@ const PaymentMethodSelection = ({ refetch: refetchPaymentMethods, } = useOrganizationPaymentMethodsQuery({ slug }) + const captchaRefCallback = useCallback((node: any) => { + setCaptchaRef(node) + }, []) + + const { mutate: initSetupIntent, isLoading: setupIntentLoading } = + useOrganizationPaymentMethodSetupIntent({ + onSuccess: (intent) => { + setSetupIntent(intent) + }, + onError: (error) => { + toast.error(`Failed to setup intent: ${error.message}`) + }, + }) + + useEffect(() => { + if (paymentMethods?.data && paymentMethods.data.length === 0 && setupNewPaymentMethod == null) { + setSetupNewPaymentMethod(true) + } + }, [paymentMethods]) + + useEffect(() => { + const loadSetupIntent = async (hcaptchaToken: string | undefined) => { + const slug = selectedOrganization?.slug + if (!slug) return console.error('Slug is required') + if (!hcaptchaToken) return console.error('HCaptcha token required') + + setSetupIntent(undefined) + initSetupIntent({ slug: slug!, hcaptchaToken }) + } + + const loadPaymentForm = async () => { + if (setupNewPaymentMethod && createPaymentMethodInline && captchaRef && captchaLoaded) { + let token = captchaToken + + try { + if (!token) { + const captchaResponse = await captchaRef.execute({ async: true }) + token = captchaResponse?.response ?? null + } + } catch (error) { + return + } + + await loadSetupIntent(token ?? undefined) + resetCaptcha() + } + } + + loadPaymentForm() + }, [createPaymentMethodInline, captchaRef, captchaLoaded, setupNewPaymentMethod]) + + const resetCaptcha = () => { + setCaptchaToken(null) + captchaRef?.resetCaptcha() + } + const canUpdatePaymentMethods = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.payment_methods' ) + const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo( + () => + ({ + clientSecret: setupIntent ? setupIntent.client_secret! : '', + appearance: getStripeElementsAppearanceOptions(resolvedTheme), + paymentMethodCreation: 'manual', + }) as const, + [setupIntent, resolvedTheme] + ) + useEffect(() => { if (paymentMethods?.data && paymentMethods.data.length > 0) { const selectedPaymentMethodExists = paymentMethods.data.some( @@ -55,15 +155,49 @@ const PaymentMethodSelection = ({ } }, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod]) + // If createPaymentMethod already exists, use it. Otherwise, define it here. + const createPaymentMethod = async () => { + if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) { + return paymentRef.current?.createPaymentMethod() + } else { + return { id: selectedPaymentMethod } + } + } + + useImperativeHandle(ref, () => ({ + createPaymentMethod, + })) + return ( <> + { + // [Joshen] This is to ensure that hCaptcha popup remains clickable + if (document !== undefined) document.body.classList.add('!pointer-events-auto') + }} + onClose={() => { + setSetupIntent(undefined) + if (document !== undefined) document.body.classList.remove('!pointer-events-auto') + }} + onVerify={(token) => { + setCaptchaToken(token) + if (document !== undefined) document.body.classList.remove('!pointer-events-auto') + }} + onExpire={() => { + setCaptchaToken(null) + }} + /> +
{isLoading ? (

Retrieving payment methods

- ) : paymentMethods?.data.length === 0 ? ( + ) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? (
@@ -74,7 +208,13 @@ const PaymentMethodSelection = ({ type="default" disabled={!canUpdatePaymentMethods} icon={} - onClick={() => setShowAddNewPaymentMethodModal(true)} + onClick={() => { + if (createPaymentMethodInline) { + setSetupNewPaymentMethod(true) + } else { + setShowAddNewPaymentMethodModal(true) + } + }} htmlType="button" tooltip={{ content: { @@ -93,7 +233,7 @@ const PaymentMethodSelection = ({ Add new
- ) : ( + ) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? ( setShowAddNewPaymentMethodModal(true)} + onClick={() => { + if (createPaymentMethodInline) { + setSetupNewPaymentMethod(true) + } else { + setShowAddNewPaymentMethodModal(true) + } + }} >

@@ -134,6 +280,28 @@ const PaymentMethodSelection = ({

+ ) : null} + + {stripePromise && setupIntent && ( + + + + )} + + {setupIntentLoading && ( +
+ +
+ + +
+ +
)}
@@ -158,6 +326,8 @@ const PaymentMethodSelection = ({ /> ) -} +}) + +PaymentMethodSelection.displayName = 'PaymentMethodSelection' export default PaymentMethodSelection diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 40f7fd83c70a4..4102d6f65bbc7 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -28,6 +28,7 @@ import MembersExceedLimitModal from './MembersExceedLimitModal' import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog' import UpgradeSurveyModal from './UpgradeModal' import PartnerManagedResource from 'components/ui/PartnerManagedResource' +import { getPlanChangeType } from 'components/interfaces/Billing/Subscription/Subscription.utils' const PlanUpdateSidePanel = () => { const router = useRouter() @@ -70,7 +71,6 @@ const PlanUpdateSidePanel = () => { const { data: plans, isLoading: isLoadingPlans } = useOrgPlansQuery({ orgSlug: slug }) const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) - const billingViaPartner = subscription?.billing_via_partner === true const billingPartner = subscription?.billing_partner const { @@ -84,7 +84,6 @@ const PlanUpdateSidePanel = () => { const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 && orgProjects.filter((it) => it.status !== 'INACTIVE' && it.status !== 'GOING_DOWN').length > 0 - const subscriptionPlanMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier) useEffect(() => { if (visible) { @@ -161,7 +160,8 @@ const PlanUpdateSidePanel = () => { {subscriptionsPlans.map((plan) => { const planMeta = availablePlans.find((p) => p.id === plan.id.split('tier_')[1]) const price = planMeta?.price ?? 0 - const isDowngradeOption = planMeta?.change_type === 'downgrade' + const isDowngradeOption = + getPlanChangeType(subscription?.plan.id, plan?.planId) === 'downgrade' const isCurrentPlan = planMeta?.id === subscription?.plan?.id const features = pickFeatures(plan, billingPartner) const footer = pickFooter(plan, billingPartner) @@ -299,7 +299,6 @@ const PlanUpdateSidePanel = () => { setSelectedTier(undefined)} onConfirm={onConfirmDowngrade} @@ -308,16 +307,12 @@ const PlanUpdateSidePanel = () => { setSelectedTier(undefined)} - subscriptionPlanMeta={subscriptionPlanMeta} planMeta={planMeta} subscriptionPreviewError={subscriptionPreviewError} subscriptionPreviewIsLoading={subscriptionPreviewIsLoading} subscriptionPreviewInitialized={subscriptionPreviewInitialized} subscriptionPreview={subscriptionPreview} - billingViaPartner={billingViaPartner} - billingPartner={billingPartner} subscription={subscription} projects={orgProjects} currentPlanMeta={{ diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 0bee353c27bf9..9cc8fbc274fe4 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -1,11 +1,14 @@ import { useQueryClient } from '@tanstack/react-query' import { Check, InfoIcon } from 'lucide-react' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import tweets from 'shared-data/tweets' import { toast } from 'sonner' -import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { + billingPartnerLabel, + getPlanChangeType, +} from 'components/interfaces/Billing/Subscription/Subscription.utils' import AlertError from 'components/ui/AlertError' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { organizationKeys } from 'data/organizations/keys' @@ -14,13 +17,23 @@ import { ProjectInfo } from 'data/projects/projects-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' import { SubscriptionTier } from 'data/subscriptions/types' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS } from 'lib/constants' +import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS, STRIPE_PUBLIC_KEY } from 'lib/constants' import { formatCurrency } from 'lib/helpers' import { Badge, Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui' import { Admonition } from 'ui-patterns' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { BillingCustomerDataExistingOrgDialog } from '../BillingCustomerData/BillingCustomerDataExistingOrgDialog' import PaymentMethodSelection from './PaymentMethodSelection' +import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change' +import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' +import { Elements } from '@stripe/react-stripe-js' +import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' +import { useTheme } from 'next-themes' +import { PaymentIntentResult } from '@stripe/stripe-js' +import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' +import { plans as subscriptionsPlans } from 'shared-data/plans' + +const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) const getRandomTweet = () => { const filteredTweets = tweets.filter((it) => it.text.length < 180) @@ -49,15 +62,11 @@ type DowngradePlanHeadingKey = keyof typeof DOWNGRADE_PLAN_HEADINGS interface Props { selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined onClose: () => void - subscriptionPlanMeta: any planMeta: any subscriptionPreviewError: any subscriptionPreviewIsLoading: boolean subscriptionPreviewInitialized: boolean subscriptionPreview: OrganizationBillingSubscriptionPreviewResponse | undefined - billingViaPartner: boolean - billingPartner?: string - selectedOrganization: any subscription: any currentPlanMeta: any projects: ProjectInfo[] @@ -66,38 +75,63 @@ interface Props { export const SubscriptionPlanUpdateDialog = ({ selectedTier, onClose, - subscriptionPlanMeta, planMeta, subscriptionPreviewError, subscriptionPreviewIsLoading, subscriptionPreviewInitialized, subscriptionPreview, - billingViaPartner, - billingPartner, - selectedOrganization, subscription, currentPlanMeta, projects, }: Props) => { + const { resolvedTheme } = useTheme() const queryClient = useQueryClient() - const { slug } = useSelectedOrganization() ?? {} + const selectedOrganization = useSelectedOrganization() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() - const [testimonialTweet, setTestimonialTweet] = useState(getRandomTweet()) + const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) + const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) + const paymentMethodSelection = useRef<{ + createPaymentMethod: () => Promise + }>(null) + + const billingViaPartner = subscription?.billing_via_partner === true + const billingPartner = subscription?.billing_partner + + const stripeOptionsConfirm = useMemo(() => { + return { + clientSecret: paymentIntentSecret, + appearance: getStripeElementsAppearanceOptions(resolvedTheme), + } as StripeElementsOptions + }, [paymentIntentSecret, resolvedTheme]) + + const testimonialTweet = useMemo(() => getRandomTweet(), []) + + const changeType = useMemo(() => { + return getPlanChangeType(subscription?.plan?.id, planMeta?.id) + }, [planMeta, subscription]) + + const subscriptionPlanMeta = useMemo( + () => subscriptionsPlans.find((tier) => tier.id === selectedTier), + [selectedTier] + ) - useEffect(() => { - if (selectedTier !== undefined && selectedTier !== 'tier_free') { - setTestimonialTweet(getRandomTweet()) - } - }, [selectedTier]) + const onSuccessfulPlanChange = () => { + toast.success( + `Successfully ${changeType === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` + ) + onClose() + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + } const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation( { - onSuccess: () => { - toast.success( - `Successfully ${planMeta?.change_type === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` - ) - onClose() - window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + onSuccess: (data) => { + if (data.pending_payment_intent_secret) { + setPaymentIntentSecret(data.pending_payment_intent_secret) + return + } + + onSuccessfulPlanChange() }, onError: (error) => { toast.error(`Unable to update subscription: ${error.message}`) @@ -105,25 +139,64 @@ export const SubscriptionPlanUpdateDialog = ({ } ) + const { mutate: confirmPendingSubscriptionChange, isLoading: isConfirming } = + useConfirmPendingSubscriptionChangeMutation({ + onSuccess: () => { + onSuccessfulPlanChange() + }, + onError: (error) => { + toast.error(`Unable to update subscription: ${error.message}`) + }, + }) + + const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => { + // Reset payment intent secret to ensure another attempt works as expected + setPaymentIntentSecret('') + + if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') { + await confirmPendingSubscriptionChange({ + slug: selectedOrganization?.slug, + payment_intent_id: paymentIntentConfirmation.paymentIntent.id, + }) + } else { + setPaymentConfirmationLoading(false) + // If the payment intent is not successful, we reset the payment method and show an error + toast.error(`Could not confirm payment. Please try again or use a different card.`) + } + } + const onUpdateSubscription = async () => { - if (!slug) return console.error('org slug is required') + if (!selectedOrganization?.slug) return console.error('org slug is required') if (!selectedTier) return console.error('Selected plan is required') - if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') { - return toast.error('Please select a payment method') + + const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod() + if (paymentMethod) { + setSelectedPaymentMethod(paymentMethod.id) } - if (selectedPaymentMethod) { - queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => { - if (!prev) return prev - return { - ...prev, - defaultPaymentMethodId: selectedPaymentMethod, - data: prev.data.map((pm: any) => ({ - ...pm, - is_default: pm.id === selectedPaymentMethod, - })), + if ( + !paymentMethod && + subscription?.payment_method_type !== 'invoice' && + changeType === 'upgrade' + ) { + return + } + + if (paymentMethod) { + queryClient.setQueriesData( + organizationKeys.paymentMethods(selectedOrganization.slug), + (prev: any) => { + if (!prev) return prev + return { + ...prev, + defaultPaymentMethodId: paymentMethod?.id, + data: prev.data.map((pm: any) => ({ + ...pm, + is_default: pm.id === paymentMethod?.id, + })), + } } - }) + ) } // If the user is downgrading from team, should have spend cap disabled by default @@ -132,7 +205,11 @@ export const SubscriptionPlanUpdateDialog = ({ ? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier) : selectedTier - updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod }) + updateOrgSubscription({ + slug: selectedOrganization?.slug, + tier, + paymentMethod: paymentMethod?.id, + }) } const features = subscriptionPlanMeta?.features?.[0]?.features || [] @@ -143,11 +220,11 @@ export const SubscriptionPlanUpdateDialog = ({ // Features that will be lost when downgrading const featuresToLose = - planMeta?.change_type === 'downgrade' + changeType === 'downgrade' ? currentPlanFeatures.filter((feature: string | [string, ...any[]]) => { const featureStr = typeof feature === 'string' ? feature : feature[0] // Check if this feature exists in the new plan - return !topFeatures.some((newFeature: string | [string, ...any[]]) => { + return !topFeatures.some((newFeature: string | string[]) => { const newFeatureStr = typeof newFeature === 'string' ? newFeature : newFeature[0] return newFeatureStr === featureStr }) @@ -165,7 +242,7 @@ export const SubscriptionPlanUpdateDialog = ({ const proratedCredit = currentPlanMonthlyPrice * remainingRatio // Calculate new plan cost - const newPlanCost = subscriptionPlanMeta?.priceMonthly ?? 0 + const newPlanCost = Number(subscriptionPlanMeta?.priceMonthly) ?? 0 const customerBalance = ((subscription?.customer_balance ?? 0) / 100) * -1 @@ -182,16 +259,16 @@ export const SubscriptionPlanUpdateDialog = ({ event.preventDefault()} size="xlarge" - className="p-0" + className="p-0 overflow-y-auto max-h-[1000px]" > -
+
{/* Left Column */} -
+

- {planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} + {changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} {selectedOrganization?.name} to{' '} - {planMeta?.change_type === 'downgrade' + {changeType === 'downgrade' ? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default'] : PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}

@@ -256,7 +333,7 @@ export const SubscriptionPlanUpdateDialog = ({ <> {' '} @@ -483,16 +560,24 @@ export const SubscriptionPlanUpdateDialog = ({ )}
-
- {!billingViaPartner ? ( +
+ {!billingViaPartner && !subscriptionPreviewIsLoading && changeType === 'upgrade' && (
+
- ) : ( + )} + + {billingViaPartner && (

This organization is billed through our partner{' '} @@ -518,40 +603,42 @@ export const SubscriptionPlanUpdateDialog = ({ (it) => it.status === PROJECT_STATUS.ACTIVE_HEALTHY || it.status === PROJECT_STATUS.COMING_UP - ).length === 0 && ( -

- - This organization has no active projects. Did you select the correct - organization? - -
- )} + ).length === 0 && + subscriptionPreview?.plan_change_type !== 'downgrade' && ( +
+ + This organization has no active projects. Did you select the correct + organization? + +
+ )}
{/* Right Column */} -
- {planMeta?.change_type === 'downgrade' +
+ {changeType === 'downgrade' ? featuresToLose.length > 0 && (

Features you'll lose

- Please review this carefully before downgrading. + Please review carefully before downgrading.

{featuresToLose.map((feature: string | [string, ...any[]]) => ( @@ -575,7 +662,7 @@ export const SubscriptionPlanUpdateDialog = ({

Upgrade features

- {topFeatures.map((feature: string | [string, ...any[]]) => ( + {topFeatures.map((feature: string | string[]) => (
)} - {planMeta?.change_type !== 'downgrade' && ( + {changeType !== 'downgrade' && (
{testimonialTweet.text} @@ -604,6 +691,19 @@ export const SubscriptionPlanUpdateDialog = ({ )}
+ + {stripePromise && paymentIntentSecret && ( + + + paymentIntentConfirmed(paymentIntentConfirmation) + } + onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} + paymentMethodId={selectedPaymentMethod!} + /> + + )} ) diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 066177fb1587f..a544b10ef5d60 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -1,11 +1,11 @@ -import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import { Elements } from '@stripe/react-stripe-js' import type { PaymentIntentResult, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' import _ from 'lodash' import { ExternalLink, HelpCircle } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { parseAsString, useQueryStates } from 'nuqs' -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { z } from 'zod' @@ -19,7 +19,6 @@ import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from 'lib/constants' import { Button, - Input, Input_Shadcn_, Label_Shadcn_, Select_Shadcn_, @@ -35,13 +34,12 @@ import { import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { BillingCustomerDataNewOrgDialog } from '../BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog' import { FormCustomerData } from '../BillingSettings/BillingCustomerData/useBillingCustomerDataForm' -import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change' +import { useConfirmPendingSubscriptionCreateMutation } from 'data/subscriptions/org-subscription-confirm-pending-create' import { loadStripe } from '@stripe/stripe-js' import { useTheme } from 'next-themes' import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation' import { useProfile } from 'lib/profile' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' -import { getURL } from 'lib/helpers' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' import { NewPaymentMethodElement } from '../BillingSettings/PaymentMethods/NewPaymentMethodElement' @@ -190,7 +188,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr }, }) - const { mutate: confirmPendingSubscriptionChange } = useConfirmPendingSubscriptionChangeMutation({ + const { mutate: confirmPendingSubscriptionChange } = useConfirmPendingSubscriptionCreateMutation({ onSuccess: (data) => { if (data && 'slug' in data) { onOrganizationCreated({ slug: data.slug }) @@ -557,6 +555,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr setupIntent?.pending_subscription_flow_enabled_for_creation === true } email={user.profile?.primary_email} + readOnly={newOrgLoading || paymentConfirmationLoading} /> diff --git a/apps/studio/data/organizations/organization-billing-subscription-preview.ts b/apps/studio/data/organizations/organization-billing-subscription-preview.ts index beb079b520e2b..0f0adef3bb04e 100644 --- a/apps/studio/data/organizations/organization-billing-subscription-preview.ts +++ b/apps/studio/data/organizations/organization-billing-subscription-preview.ts @@ -43,6 +43,7 @@ export type OrganizationBillingSubscriptionPreviewResponse = { ref: string }[] billed_via_partner?: boolean + pending_subscription_flow?: boolean } export async function previewOrganizationBillingSubscription({ diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts index ed6065771c845..44df661518567 100644 --- a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts @@ -4,31 +4,36 @@ import { toast } from 'sonner' import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' import { organizationKeys } from 'data/organizations/keys' -import { permissionKeys } from 'data/permissions/keys' -import { castOrganizationResponseToOrganization } from 'data/organizations/organizations-query' -import type { components } from 'api-types' +import { subscriptionKeys } from './keys' +import { usageKeys } from 'data/usage/keys' +import { invoicesKeys } from 'data/invoices/keys' export type PendingSubscriptionChangeVariables = { payment_intent_id: string - name: string - kind?: string - size?: string + slug?: string } export async function confirmPendingSubscriptionChange({ payment_intent_id, - name, - kind, - size, + slug, }: PendingSubscriptionChangeVariables) { - const { data, error } = await post('/platform/organizations/confirm-subscription', { - body: { - payment_intent_id, - name, - kind, - size, - }, - }) + if (!slug) { + throw new Error('Organization slug is required to confirm pending subscription change') + } + + const { data, error } = await post( + '/platform/organizations/{slug}/billing/subscription/confirm', + { + params: { + path: { + slug, + }, + }, + body: { + payment_intent_id, + }, + } + ) if (error) handleError(error) return data @@ -56,33 +61,28 @@ export const useConfirmPendingSubscriptionChangeMutation = ({ PendingSubscriptionChangeVariables >((vars) => confirmPendingSubscriptionChange(vars), { async onSuccess(data, variables, context) { - // [Joshen] We're manually updating the query client here as the org's subscription is - // created async, and the invalidation will happen too quick where the GET organizations - // endpoint will error out with a 500 since the subscription isn't created yet. - queryClient.setQueriesData( - { - queryKey: organizationKeys.list(), - exact: true, - }, - (prev: any) => { - if (!prev) return prev - return [ - ...prev, - castOrganizationResponseToOrganization( - data as components['schemas']['OrganizationResponse'] - ), - ] - } - ) + const { slug } = variables - await queryClient.invalidateQueries(permissionKeys.list()) + // [Kevin] Backend can return stale data as it's waiting for the Stripe-sync to complete. Until that's solved in the backend + // we are going back to monkey here and delay the invalidation + await new Promise((resolve) => setTimeout(resolve, 2000)) - // todo replace plan in org + await Promise.all([ + queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug)), + queryClient.invalidateQueries(subscriptionKeys.orgPlans(slug)), + queryClient.invalidateQueries(usageKeys.orgUsage(slug)), + queryClient.invalidateQueries(invoicesKeys.orgUpcomingPreview(slug)), + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.list()), + ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to confirm payment: ${data.message}`) + toast.error(data.message, { + dismissible: true, + duration: 10_000, + }) } else { onError(data, variables, context) } diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts new file mode 100644 index 0000000000000..8d60276bb9447 --- /dev/null +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts @@ -0,0 +1,95 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { organizationKeys } from 'data/organizations/keys' +import { permissionKeys } from 'data/permissions/keys' +import { castOrganizationResponseToOrganization } from 'data/organizations/organizations-query' +import type { components } from 'api-types' + +export type PendingSubscriptionCreateVariables = { + payment_intent_id: string + name: string + kind?: string + size?: string +} + +export async function confirmPendingSubscriptionCreate({ + payment_intent_id, + name, + kind, + size, +}: PendingSubscriptionCreateVariables) { + const { data, error } = await post('/platform/organizations/confirm-subscription', { + body: { + payment_intent_id, + name, + kind, + size, + }, + }) + + if (error) handleError(error) + return data +} + +type PendingSubscriptionCreateData = Awaited> + +export const useConfirmPendingSubscriptionCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + PendingSubscriptionCreateData, + ResponseError, + PendingSubscriptionCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + PendingSubscriptionCreateData, + ResponseError, + PendingSubscriptionCreateVariables + >((vars) => confirmPendingSubscriptionCreate(vars), { + async onSuccess(data, variables, context) { + // [Joshen] We're manually updating the query client here as the org's subscription is + // created async, and the invalidation will happen too quick where the GET organizations + // endpoint will error out with a 500 since the subscription isn't created yet. + queryClient.setQueriesData( + { + queryKey: organizationKeys.list(), + exact: true, + }, + (prev: any) => { + if (!prev) return prev + return [ + ...prev, + castOrganizationResponseToOrganization( + data as components['schemas']['OrganizationResponse'] + ), + ] + } + ) + + await queryClient.invalidateQueries(permissionKeys.list()) + + // todo replace plan in org + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(data.message, { + dismissible: true, + duration: 10_000, + }) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts index 348eb311780b7..d899039b23cb8 100644 --- a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -55,24 +55,29 @@ export const useOrgSubscriptionUpdateMutation = ({ async onSuccess(data, variables, context) { const { slug } = variables - // [Kevin] Backend can return stale data as it's waiting for the Stripe-sync to complete. Until that's solved in the backend - // we are going back to monkey here and delay the invalidation - await new Promise((resolve) => setTimeout(resolve, 2000)) + if (!data.pending_payment_intent_secret) { + // [Kevin] Backend can return stale data as it's waiting for the Stripe-sync to complete. Until that's solved in the backend + // we are going back to monkey here and delay the invalidation + await new Promise((resolve) => setTimeout(resolve, 2000)) - await Promise.all([ - queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug)), - queryClient.invalidateQueries(subscriptionKeys.orgPlans(slug)), - queryClient.invalidateQueries(usageKeys.orgUsage(slug)), - queryClient.invalidateQueries(invoicesKeys.orgUpcomingPreview(slug)), - queryClient.invalidateQueries(organizationKeys.detail(slug)), - queryClient.invalidateQueries(organizationKeys.list()), - ]) + await Promise.all([ + queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug)), + queryClient.invalidateQueries(subscriptionKeys.orgPlans(slug)), + queryClient.invalidateQueries(usageKeys.orgUsage(slug)), + queryClient.invalidateQueries(invoicesKeys.orgUpcomingPreview(slug)), + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.list()), + ]) + } await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to update subscription: ${data.message}`) + toast.error(data.message, { + dismissible: true, + duration: 10_000, + }) } else { onError(data, variables, context) } diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 8a43fd88679b4..88d51e24ff2dd 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -368,7 +368,7 @@ export interface paths { /** Get project api keys */ get: operations['v1-get-project-api-keys'] put?: never - /** [Beta] Creates a new API key for the project */ + /** Creates a new API key for the project */ post: operations['createApiKey'] delete?: never options?: never @@ -383,15 +383,15 @@ export interface paths { path?: never cookie?: never } - /** [Beta] Get API key */ + /** Get API key */ get: operations['getApiKey'] put?: never post?: never - /** [Beta] Deletes an API key for the project */ + /** Deletes an API key for the project */ delete: operations['deleteApiKey'] options?: never head?: never - /** [Beta] Updates an API key for the project */ + /** Updates an API key for the project */ patch: operations['updateApiKey'] trace?: never } @@ -402,9 +402,9 @@ export interface paths { path?: never cookie?: never } - /** [Beta] Check whether JWT based legacy (anon, service_role) API keys are enabled. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */ + /** Check whether JWT based legacy (anon, service_role) API keys are enabled. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */ get: operations['checkLegacyApiKeys'] - /** [Beta] Disable or re-enable JWT based legacy (anon, service_role) API keys. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */ + /** Disable or re-enable JWT based legacy (anon, service_role) API keys. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */ put: operations['updateLegacyApiKeys'] post?: never delete?: never diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 5b677bd412896..3107d66b7f4d7 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -1007,6 +1007,23 @@ export interface paths { patch?: never trace?: never } + '/platform/organizations/{slug}/billing/subscription/confirm': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Confirm subscription change */ + post: operations['SubscriptionController_confirmSubscriptionChange'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/organizations/{slug}/billing/subscription/preview': { parameters: { query?: never @@ -4257,6 +4274,9 @@ export interface components { payment_intent_id: string size?: string } + ConfirmSubscriptionChangeBody: { + payment_intent_id: string + } CopyObjectBody: { from: string to: string @@ -8246,6 +8266,9 @@ export interface components { /** @enum {string} */ tier: 'tier_free' | 'tier_pro' | 'tier_payg' | 'tier_team' | 'tier_enterprise' } + UpdateSubscriptionResponse: { + pending_payment_intent_secret: string | null + } UpdateSupavisorConfigBody: { default_pool_size?: number | null /** @@ -10701,7 +10724,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['UpdateSubscriptionResponse'] + } } 403: { headers: { @@ -10718,6 +10743,43 @@ export interface operations { } } } + SubscriptionController_confirmSubscriptionChange: { + parameters: { + query?: never + header?: never + path: { + /** @description Organization slug */ + slug: string + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['ConfirmSubscriptionChangeBody'] + } + } + responses: { + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Failed to confirm subscription change */ + 500: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } SubscriptionController_previewSubscriptionChange: { parameters: { query?: never From b914d108703646dda513568538cc32af3f852d4e Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Wed, 18 Jun 2025 09:38:18 +0200 Subject: [PATCH 4/6] docs: self-hosted and HIPAA (#36485) --- apps/docs/content/guides/security/hipaa-compliance.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/docs/content/guides/security/hipaa-compliance.mdx b/apps/docs/content/guides/security/hipaa-compliance.mdx index 05da2d2d67002..59b96c4d85ba0 100644 --- a/apps/docs/content/guides/security/hipaa-compliance.mdx +++ b/apps/docs/content/guides/security/hipaa-compliance.mdx @@ -8,6 +8,12 @@ The [Health Insurance Portability and Accountability Act (HIPAA)](https://www.hh Under HIPAA, both covered entities and business associates have distinct responsibilities to ensure the protection of PHI. Supabase acts as a business associate for customers (the covered entity) who wish to provide healthcare related services. As a business associate, Supabase has a number of obligations and has undergone auditing of the security and privacy controls that are in place to meet these. Supabase has signed a Business Associate Agreement (BAA) with all of our vendors who would have access to ePHI, such as AWS, and ensure that we follow their terms listed in the agreements. Similarly when a customer signs a BAA with us, they have some responsibilities they agree to when using Supabase to store PHI. + + +The hosted Supabase platform has the necessary controls to meet HIPAA requirements. These controls are not supported out of the box in self-hosted Supabase. HIPAA controls extend further than the Supabase product, encompassing legal agreements (BAAs) with providers, operating controls and policies. Achieving HIPAA compliance with self-hosted Supabase is out of scope for this documentation and you should consult your auditor for further guidance. + + + ### Customer responsibilities Covered entities (the customer) are organizations that directly handle PHI, such as health plans, healthcare clearinghouses, and healthcare providers that conduct certain electronic transactions. From ef20349e7eb0a8687ce6bdb545371a31c26e8c46 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 18 Jun 2025 11:12:17 +0200 Subject: [PATCH 5/6] API Logs improvements FDBK-29499 (#36479) add search, improve event message --- .../interfaces/Settings/Logs/LogSelection.tsx | 2 + .../DefaultPreviewSelectionRenderer.tsx | 51 ++++++++++++++----- .../interfaces/Settings/Logs/Logs.utils.ts | 4 +- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx index 9627a48d96c55..ffa1e699e221b 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx @@ -47,6 +47,7 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio const status = log?.metadata?.[0]?.response?.[0]?.status_code const method = log?.metadata?.[0]?.request?.[0]?.method const path = log?.metadata?.[0]?.request?.[0]?.path + const search = log?.metadata?.[0]?.request?.[0]?.search const user_agent = log?.metadata?.[0]?.request?.[0]?.headers[0].user_agent const error_code = log?.metadata?.[0]?.response?.[0]?.headers?.[0]?.x_sb_error_code const apikey = jwtAPIKey(log?.metadata) ?? apiKey(log?.metadata) @@ -59,6 +60,7 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio status, method, path, + search, user_agent, timestamp, event_message, diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx index 5da4f516bee66..e741d74fe436d 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx @@ -18,7 +18,7 @@ import { ResponseCodeFormatter } from '../LogsFormatters' const LogRowCodeBlock = ({ value, className }: { value: string; className?: string }) => (
@@ -47,8 +47,18 @@ const PropertyRow = ({
   const isObject = typeof value === 'object' && value !== null
   const isStatus = keyName === 'status' || keyName === 'status_code'
   const isMethod = keyName === 'method'
-  const isPath = keyName === 'path'
+  const isSearch = keyName === 'search'
   const isUserAgent = keyName === 'user_agent'
+  const isEventMessage = keyName === 'event_message'
+  const isPath = keyName === 'path'
+
+  function getSearchPairs() {
+    if (isSearch && typeof value === 'string') {
+      const str = value.startsWith('?') ? value.slice(1) : value
+      return str.split('&').filter(Boolean)
+    }
+    return []
+  }
 
   const storageKey = `log-viewer-expanded-${keyName}`
   const [isExpanded, setIsExpanded] = useState(() => {
@@ -76,7 +86,7 @@ const PropertyRow = ({
     }, 1000)
   }
 
-  if (isObject) {
+  if (isObject || isEventMessage) {
     return (
       <>
         
@@ -86,17 +96,20 @@ const PropertyRow = ({ className={cn('px-2.5', { 'max-h-[80px]': !isExpanded, 'max-h-[400px]': isExpanded, + 'py-2': isEventMessage, })} value={value} /> - + {!isEventMessage && ( + + )}
@@ -141,7 +154,7 @@ const PropertyRow = ({
) : ( -
{JSON.stringify(value)}
+
{value}
)}
@@ -158,7 +171,7 @@ const PropertyRow = ({ {isExpanded ? 'Collapse' : 'Expand'} value )} - {(isPath || isMethod || isUserAgent || isStatus) && ( + {(isMethod || isUserAgent || isStatus || isPath) && ( { handleSearch('search-input-change', { query: value }) @@ -167,6 +180,18 @@ const PropertyRow = ({ Search by {keyName} )} + {isSearch + ? getSearchPairs().map((pair) => ( + { + handleSearch('search-input-change', { query: pair }) + }} + > + Search by {pair} + + )) + : null} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index ad68e1a781a47..1af9461d3f93b 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -145,7 +145,7 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters, limit: n if (IS_PLATFORM === false) { return ` -- local dev edge_logs query -select id, edge_logs.timestamp, event_message, request.method, request.path, response.status_code +select id, edge_logs.timestamp, event_message, request.method, request.path, request.search, response.status_code from edge_logs ${joins} ${where} @@ -153,7 +153,7 @@ ${orderBy} limit ${limit}; ` } - return `select id, identifier, timestamp, event_message, request.method, request.path, response.status_code + return `select id, identifier, timestamp, event_message, request.method, request.path, request.search, response.status_code from ${table} ${joins} ${where} From aa1d904f4dc08ca61646372f6e33577b027e24a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cemal=20K=C4=B1l=C4=B1=C3=A7?= Date: Wed, 18 Jun 2025 11:57:50 +0200 Subject: [PATCH 6/6] feat: list allowed symbols for password security (#36470) * feat: list allowed symbols for password security * fix: formatting --- apps/docs/content/guides/auth/password-security.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/auth/password-security.mdx b/apps/docs/content/guides/auth/password-security.mdx index 4582c5ed77a41..4a9124eaa1fc3 100644 --- a/apps/docs/content/guides/auth/password-security.mdx +++ b/apps/docs/content/guides/auth/password-security.mdx @@ -23,7 +23,7 @@ There are hundreds of millions (and growing!) known passwords out there. Malicio To help protect your users, Supabase Auth allows you fine-grained control over the strength of the passwords used on your project. You can configure these in your project's [Auth settings](/dashboard/project/_/auth/providers?provider=Email): - Set a large minimum password length. Anything less than 8 characters is not recommended. -- Set the required characters that must appear at least once in a user's password. Use the strongest option of requiring digits, lowercase and uppercase letters, and symbols. +- Set the required characters that must appear at least once in a user's password. Use the strongest option of requiring digits, lowercase and uppercase letters, and symbols. The allowed symbols are: ``!@#$%^&*()_+-=[]{};'\:"|<>?,./`~`` - Prevent the use of leaked passwords. Supabase Auth uses the open-source [HaveIBeenPwned.org Pwned Passwords API](https://haveibeenpwned.com/Passwords) to reject passwords that have been leaked and are known by malicious actors.