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, + } + ) +}