mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 14:19:11 +03:00
Add payment methods to org billing v2
This commit is contained in:
1 parent
0ed5e40c4c
commit
cc32db3281
9 files changed
+779
-3
No files matched your search
@@ -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 = () => {
|
||||
<LayoutWrapper>
|
||||
<CreditBalance />
|
||||
</LayoutWrapper>
|
||||
<LayoutWrapper>
|
||||
<PaymentMethods />
|
||||
</LayoutWrapper>
|
||||
<LayoutWrapper>
|
||||
<BillingEmail />
|
||||
</LayoutWrapper>
|
||||
|
||||
+80
@@ -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 (
|
||||
<Modal
|
||||
visible={selectedPaymentMethod !== undefined}
|
||||
size="medium"
|
||||
header={`Confirm to delete payment method ending with ${selectedPaymentMethod?.card.last4}`}
|
||||
onCancel={() => onClose()}
|
||||
customFooter={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="default" disabled={isDeleting} onClick={() => onClose()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={isDeleting}
|
||||
loading={isDeleting}
|
||||
onClick={onConfirmDelete}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="py-4">
|
||||
<Modal.Content>
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="This will permanently delete your payment method."
|
||||
>
|
||||
<p className="">
|
||||
Any subscription currently charging this payment method will start charging your
|
||||
default payment method.
|
||||
</p>
|
||||
</Alert>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeletePaymentMethodModal
|
||||
+180
@@ -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<any>()
|
||||
const [selectedMethodToDelete, setSelectedMethodToDelete] = useState<any>()
|
||||
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 (
|
||||
<>
|
||||
<FormSection
|
||||
id="payment-methods"
|
||||
header={
|
||||
<FormSectionLabel>
|
||||
<div className="sticky space-y-6 top-16">
|
||||
<div>
|
||||
<p className="text-base">Payment methods</p>
|
||||
<p className="text-sm text-scale-1000 mb-2">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
{!canReadPaymentMethods ? (
|
||||
<NoPermission resourceText="view this organization's payment methods" />
|
||||
) : (
|
||||
<FormPanel
|
||||
footer={
|
||||
<div className="flex items-center justify-between py-4 px-8">
|
||||
{!canUpdatePaymentMethods ? (
|
||||
<p className="text-sm text-scale-1000">
|
||||
You need additional permissions to manage payment methods
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconPlus />}
|
||||
disabled={!canUpdatePaymentMethods}
|
||||
onClick={() => setShowAddPaymentMethodModal(true)}
|
||||
>
|
||||
Add new card
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection>
|
||||
<FormSectionContent fullWidth loading={false}>
|
||||
{(paymentMethods?.length ?? 0) === 0 ? (
|
||||
<div className="flex items-center space-x-2 opacity-50">
|
||||
<IconCreditCard />
|
||||
<p className="text-sm">No payment methods</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paymentMethods?.map((paymentMethod) => {
|
||||
const activePaymentMethod =
|
||||
subscription?.payment_method_id === paymentMethod.id
|
||||
return (
|
||||
<div key={paymentMethod.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-8">
|
||||
<img
|
||||
alt="Credit card brand"
|
||||
src={`${BASE_PATH}/img/payment-methods/${paymentMethod.card.brand
|
||||
.replace(' ', '-')
|
||||
.toLowerCase()}.png`}
|
||||
width="32"
|
||||
/>
|
||||
<p className="prose text-sm font-mono">
|
||||
**** **** **** {paymentMethod.card.last4}
|
||||
</p>
|
||||
<p className="text-sm tabular-nums">
|
||||
Expires: {paymentMethod.card.exp_month}/
|
||||
{paymentMethod.card.exp_year}
|
||||
</p>
|
||||
{activePaymentMethod && <Badge color="green">Active</Badge>}
|
||||
</div>
|
||||
{canUpdatePaymentMethods ? (
|
||||
<Dropdown
|
||||
size="tiny"
|
||||
align="end"
|
||||
overlay={[
|
||||
<Dropdown.Item
|
||||
key="make-default"
|
||||
onClick={() => setSelectedMethodForDefault(paymentMethod)}
|
||||
>
|
||||
Make default
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Item
|
||||
key="delete-method"
|
||||
onClick={() => setSelectedMethodToDelete(paymentMethod)}
|
||||
>
|
||||
Delete
|
||||
</Dropdown.Item>,
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="outline"
|
||||
icon={<IconMoreHorizontal />}
|
||||
className="hover:border-gray-500 px-1"
|
||||
/>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
|
||||
<AddNewPaymentMethodModal
|
||||
visible={showAddPaymentMethodModal}
|
||||
returnUrl={`${getURL()}/org/${slug}/billing`}
|
||||
onCancel={() => setShowAddPaymentMethodModal(false)}
|
||||
onConfirm={async () => {
|
||||
setShowAddPaymentMethodModal(false)
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: 'Successfully added new payment method',
|
||||
})
|
||||
await queryClient.invalidateQueries(organizationKeys.paymentMethods(slug))
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeletePaymentMethodModal
|
||||
selectedPaymentMethod={selectedMethodToDelete}
|
||||
onClose={() => setSelectedMethodToDelete(undefined)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentMethods
|
||||
+181
@@ -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 (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Select payment method</p>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center px-4 py-2 space-x-4 border rounded-md border-scale-700 bg-scale-400">
|
||||
<IconLoader className="animate-spin" size={14} />
|
||||
<p className="text-sm text-scale-1100">Retrieving payment methods</p>
|
||||
</div>
|
||||
) : paymentMethods.length === 0 ? (
|
||||
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-scale-100">
|
||||
<div className="flex items-center space-x-4 text-scale-1100">
|
||||
<IconAlertCircle size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm">No saved payment methods</p>
|
||||
</div>
|
||||
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!canUpdatePaymentMethods}
|
||||
icon={<IconCreditCard />}
|
||||
onClick={() => setShowAddNewPaymentMethodModal(true)}
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdatePaymentMethods && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow', // background
|
||||
'w-48 border border-scale-200 text-center', //border
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to add new payment methods to this
|
||||
organization
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
) : (
|
||||
<Listbox value={selectedPaymentMethod} onChange={onSelectPaymentMethod}>
|
||||
{paymentMethods.map((method: any) => {
|
||||
const label = `•••• •••• •••• ${method.card.last4}`
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={method.id}
|
||||
label={label}
|
||||
value={method.id}
|
||||
addOnBefore={() => {
|
||||
return (
|
||||
<img
|
||||
alt="Credit Card Brand"
|
||||
src={`${BASE_PATH}/img/payment-methods/${method.card.brand
|
||||
.replace(' ', '-')
|
||||
.toLowerCase()}.png`}
|
||||
width="32"
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div>{label}</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className="flex items-center px-3 py-2 space-x-2 transition cursor-pointer group hover:bg-scale-500"
|
||||
onClick={() => setShowAddNewPaymentMethodModal(true)}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
<p className="transition text-scale-1000 group-hover:text-scale-1200">
|
||||
Add new payment method
|
||||
</p>
|
||||
</div>
|
||||
</Listbox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddNewPaymentMethodModal
|
||||
visible={showAddNewPaymentMethodModal}
|
||||
returnUrl={`${getURL()}/project/${projectRef}/settings/billing/subscription?panel=subscriptionPlan`}
|
||||
onCancel={() => 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
|
||||
+5
@@ -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 && (
|
||||
<SubscriptionPaymentMethod subscription={subscription} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
|
||||
+216
@@ -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<string>(
|
||||
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 (
|
||||
<>
|
||||
<Panel>
|
||||
<Panel.Content className="flex justify-between lg:items-center flex-col md:flex-row space-y-6 md:space-y-0">
|
||||
{subscription.payment_method_type === 'card' && (
|
||||
<>
|
||||
<div className="flex space-x-3 items-center font-mono tracking-wide text-scale-1000">
|
||||
{subscription.payment_method_card_details?.brand ? (
|
||||
<img
|
||||
alt="Credit card brand"
|
||||
src={`${BASE_PATH}/img/payment-methods/${subscription.payment_method_card_details?.brand
|
||||
.replace(' ', '-')
|
||||
.toLowerCase()}.png`}
|
||||
width="24"
|
||||
/>
|
||||
) : (
|
||||
<IconCreditCard />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
**** **** ****{' '}
|
||||
{subscription?.payment_method_card_details?.last_4_digits || '****'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row space-x-3 text-scale-1000 text-sm">
|
||||
<span>
|
||||
Expires {subscription?.payment_method_card_details?.expiry_month || '-'}/
|
||||
{subscription?.payment_method_card_details?.expiry_year?.toString()?.slice(-2) ||
|
||||
'-'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{subscription?.payment_method_type === 'invoice' && (
|
||||
<>
|
||||
<div className="flex space-x-3">
|
||||
<div>
|
||||
<p className="text-scale-1100">Payment via invoice</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
You get a monthly invoice and payment link via email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={subscription?.payment_method_type !== 'card' || !canUpdatePaymentMethod}
|
||||
onClick={() => setShowPaymentEditModal(true)}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{subscription?.payment_method_type !== 'card' ? (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
Please email support@supbase.io to change your payment method.
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
) : !canUpdatePaymentMethod ? (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You do not have permission to change the payment method
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
) : null}
|
||||
</Tooltip.Root>
|
||||
</Panel.Content>
|
||||
</Panel>
|
||||
|
||||
<Modal
|
||||
loading={isSubmitting}
|
||||
alignFooter="right"
|
||||
className="!w-[450px]"
|
||||
visible={showPaymentEditModal}
|
||||
onCancel={() => setShowPaymentEditModal(false)}
|
||||
onConfirm={onConfirmPaymentMethod}
|
||||
overlayClassName="pointer-events-none"
|
||||
header={`Change payment method`}
|
||||
>
|
||||
<Modal.Content>
|
||||
<div className="py-6 space-y-2">
|
||||
<p className="text-sm">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
To remove unused or expired payment methods, head to your{' '}
|
||||
<Link href={`/org/${currentOrgSlug || '_'}/billing`} passHref>
|
||||
<a target="_blank" className="text-green-900 transition hover:text-green-1000">
|
||||
organization's billing settings
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<div className="!mt-6">
|
||||
<PaymentMethodSelection
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={setSelectedPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionPaymentMethod
|
||||
@@ -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<ReturnType<typeof deleteOrganizationMember>>
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<ReturnType<typeof getOrganizations>>
|
||||
|
||||
@@ -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<ReturnType<typeof updateOrgSubscription>>
|
||||
|
||||
export const useOrgSubscriptionUpdateMutation = ({
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<OrgSubscriptionUpdateData, unknown, OrgSubscriptionUpdateVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<OrgSubscriptionUpdateData, unknown, OrgSubscriptionUpdateVariables>(
|
||||
(vars) => updateOrgSubscription(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { slug } = variables
|
||||
await queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user