From 5e48a4e5eafbbb43142b7267aa55a098ced04b28 Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Mon, 6 Jan 2025 16:48:31 -0800 Subject: [PATCH] wip --- src/pages/PlanPage/PlanPage.jsx | 12 +- .../BillingDetails/BillingDetails.tsx | 11 +- .../EditPaymentMethod/EditPaymentMethod.tsx | 137 +++--------------- .../EditPaymentMethodForm.tsx | 93 ++++++++++++ .../PaymentCard/PaymentCard.tsx | 10 +- .../PaymentMethodInformation.tsx | 78 ++++++++++ src/services/account/useStripeSetupIntent.ts | 55 +++++++ .../account/useUpdatePaymentMethod.ts | 63 ++++++++ 8 files changed, 336 insertions(+), 123 deletions(-) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethodForm.tsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethodInformation.tsx create mode 100644 src/services/account/useStripeSetupIntent.ts create mode 100644 src/services/account/useUpdatePaymentMethod.ts diff --git a/src/pages/PlanPage/PlanPage.jsx b/src/pages/PlanPage/PlanPage.jsx index 8ae321a08c..107524e6c2 100644 --- a/src/pages/PlanPage/PlanPage.jsx +++ b/src/pages/PlanPage/PlanPage.jsx @@ -8,6 +8,7 @@ import config from 'config' import { SentryRoute } from 'sentry' +import { useStripeSetupIntent } from 'services/account/useStripeSetupIntent' import LoadingLogo from 'ui/LoadingLogo' import { PlanProvider } from './context' @@ -37,6 +38,12 @@ function PlanPage() { const { data: ownerData } = useSuspenseQueryV5( PlanPageDataQueryOpts({ owner, provider }) ) + // const { data: setupIntent } = useStripeSetupIntent({ owner, provider }) + + const setupIntent = { + clientSecret: + 'seti_1QfCiSGlVGuVgOrkPhA3FjTZ_secret_RYJLn86FhD6Co4PXYqdSkDYCgMcgZN0', + } if (config.IS_SELF_HOSTED || !ownerData?.isCurrentUserPartOfOrg) { return @@ -45,7 +52,10 @@ function PlanPage() { return (
- + }> diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx index 6cb32dc984..10952b5f7a 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx @@ -7,7 +7,7 @@ import PaymentMethod from './PaymentMethod' import Button from 'ui/Button' import { useState } from 'react' import A from 'ui/A' -import EditablePaymentMethod from './EditPaymentMethod' +import EditPaymentMethod from './EditPaymentMethod' interface URLParams { provider: string @@ -29,8 +29,6 @@ function BillingDetails() { return null } - console.log('iseditmode', isEditMode) - return (
{/* Billing Details Section */} @@ -68,7 +66,12 @@ function BillingDetails() { )}
{isEditMode ? ( - + ) : ( <> diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx index c7142559c7..23db86d014 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx @@ -1,121 +1,22 @@ -import { - Elements, - PaymentElement, - useElements, - useStripe, -} from '@stripe/react-stripe-js' -import { loadStripe } from '@stripe/stripe-js' import React, { useState } from 'react' -import Button from 'ui/Button' - import AddressForm from '../Address/AddressForm' -// TODO - fetch from API -const STRIPE_PUBLISHABLE_KEY = process.env.STRIPE_PUBLISHABLE_KEY || '' -const MANUALLY_FETCHED_CLIENT_SECRET = process.env.STRIPE_CLIENT_SECRET || '' - -const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY) - -interface PaymentFormProps { - clientSecret: string -} - -const PaymentForm: React.FC = () => { - const stripe = useStripe() - const elements = useElements() - const [isSubmitting, setIsSubmitting] = useState(false) - const [errorMessage, setErrorMessage] = useState(null) - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault() - setIsSubmitting(true) - setErrorMessage(null) - - if (!stripe || !elements) { - setErrorMessage('Stripe has not loaded yet. Please try again.') - setIsSubmitting(false) - return - } - - const { error } = await stripe.confirmPayment({ - elements, - confirmParams: { - // eslint-disable-next-line camelcase - return_url: 'https://codecov.io', - }, - }) - - if (error) { - setErrorMessage(error.message || 'An unexpected error occurred.') - setIsSubmitting(false) - } else { - setIsSubmitting(false) - } - } - - return ( -
- -
- - -
- - {errorMessage &&
{errorMessage}
} -
- ) -} - -const PaymentPage: React.FC<{ clientSecret: string }> = ({ clientSecret }) => { - const options = { - clientSecret, - appearance: { - theme: 'stripe' as const, - }, - } +import EditPaymentMethodForm from './EditPaymentMethodForm' - return ( - - - - ) +interface EditPaymentMethodProps { + isEditMode: boolean + setEditMode: (isEditMode: boolean) => void + provider: string + owner: string } -interface EditablePaymentMethodProps { - clientSecret: string -} - -const EditPaymentMethod: React.FC = () => { - const clientSecret = MANUALLY_FETCHED_CLIENT_SECRET // TODO - fetch from API - +const EditPaymentMethod = ({ + isEditMode, + setEditMode, + provider, + owner, +}: EditPaymentMethodProps) => { const [activeTab, setActiveTab] = useState<'primary' | 'secondary'>('primary') return ( @@ -143,13 +44,23 @@ const EditPaymentMethod: React.FC = () => {
{activeTab === 'primary' && (
- + {}} provider={''} owner={''} />
)} {activeTab === 'secondary' && (
- + {}} provider={''} owner={''} />
)} diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethodForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethodForm.tsx new file mode 100644 index 0000000000..1b878f4bf0 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethodForm.tsx @@ -0,0 +1,93 @@ +import { PaymentElement, useElements } from '@stripe/react-stripe-js' +import cs from 'classnames' +import { useState } from 'react' + +import { useUpdatePaymentMethod } from 'services/account/useUpdatePaymentMethod' +import { Theme, useThemeContext } from 'shared/ThemeContext' +import Button from 'ui/Button' + +interface PaymentMethodFormProps { + closeForm: () => void + provider: string + owner: string +} + +const EditPaymentMethodForm = ({ + closeForm, + provider, + owner, +}: PaymentMethodFormProps) => { + const [errorState, setErrorState] = useState('') + const { theme } = useThemeContext() + const isDarkMode = theme === Theme.DARK + + const elements = useElements() + const { + mutate: updatePaymentMethod, + isLoading, + error, + reset, + } = useUpdatePaymentMethod({ + provider, + owner, + }) + + function submit(e: React.FormEvent) { + e.preventDefault() + + if (!elements) { + return null + } + + updatePaymentMethod(elements.getElement(PaymentElement), { + onSuccess: closeForm, + }) + } + + const showError = (error && !reset) || errorState + + return ( +
+
+
+ +

+ {showError && (error?.message || errorState)} +

+
+ + +
+
+
+
+ ) +} + +export default EditPaymentMethodForm diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx index 539531f4f0..2143d802c8 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx @@ -10,7 +10,7 @@ import A from 'ui/A' import Button from 'ui/Button' import Icon from 'ui/Icon' -import CardInformation from './CardInformation' +import PaymentMethodInformation from '../PaymentMethod/PaymentMethodInformation' import CreditCardForm from './CreditCardForm' import { cn } from 'shared/utils/cn' @@ -29,7 +29,7 @@ function PaymentCard({ owner: string className?: string }) { - const card = subscriptionDetail?.defaultPaymentMethod?.card + const isPaymentMethodSet = !!subscriptionDetail?.defaultPaymentMethod return (
@@ -42,12 +42,12 @@ function PaymentCard({ owner={owner} closeForm={() => setEditMode(false)} /> - ) : card ? ( - + ) : isPaymentMethodSet ? ( + ) : (

- No credit card set. Please contact support if you think it’s an + No payment method set. Please contact support if you think it’s an error or set it yourself.

diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethodInformation.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethodInformation.tsx new file mode 100644 index 0000000000..feda49f00b --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethodInformation.tsx @@ -0,0 +1,78 @@ +import { z } from 'zod' + +import amexLogo from 'assets/billing/amex.svg' +import discoverLogo from 'assets/billing/discover.svg' +import mastercardLogo from 'assets/billing/mastercard.svg' +import visaLogo from 'assets/billing/visa.svg' +import { SubscriptionDetailSchema } from 'services/account' +import { + formatTimestampToCalendarDate, + lastTwoDigits, +} from 'shared/utils/billing' +import CardInformation from '../PaymentCard/CardInformation' + +const cardBrands = { + amex: { + logo: amexLogo, + name: 'American Express', + }, + discover: { + logo: discoverLogo, + name: 'Discover', + }, + mastercard: { + logo: mastercardLogo, + name: 'MasterCard', + }, + visa: { + logo: visaLogo, + name: 'Visa', + }, + fallback: { + logo: visaLogo, + name: 'Credit card', + }, +} + +type CardBrand = keyof typeof cardBrands + +interface PaymentMethodInformationProps { + subscriptionDetail: z.infer +} +function PaymentMethodInformation({ subscriptionDetail }: PaymentMethodInformationProps) { + const isCard = !!subscriptionDetail?.defaultPaymentMethod?.card + + if (isCard) { + return + } + + return ( +
+
+ + + + +
+ •••• {subscriptionDetail?.defaultPaymentMethod?.us_bank_account?.last4} +
+
+

+ Bank account ending in {subscriptionDetail?.defaultPaymentMethod?.us_bank_account?.last4} +

+
+ ) +} + +export default PaymentMethodInformation diff --git a/src/services/account/useStripeSetupIntent.ts b/src/services/account/useStripeSetupIntent.ts new file mode 100644 index 0000000000..37c07b55ed --- /dev/null +++ b/src/services/account/useStripeSetupIntent.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query' +import { z } from 'zod' + +import Api from 'shared/api' +import { NetworkErrorObject } from 'shared/api/helpers' + +export const StripeSetupIntentSchema = z.object({ + clientSecret: z.string(), +}) + +export interface UseStripeSetupIntentArgs { + provider: string + owner: string + opts?: { + enabled?: boolean + } +} + +function fetchStripeSetupIntent({ + provider, + owner, + signal, +}: { + provider: string + owner: string + signal?: AbortSignal +}) { + const path = `/${provider}/${owner}/account-details/setup_intent` + return Api.get({ path, provider, signal }) +} + +export function useStripeSetupIntent({ + provider, + owner, + opts = {}, +}: UseStripeSetupIntentArgs) { + return useQuery({ + queryKey: ['setupIntent', provider, owner], + queryFn: ({ signal }) => + fetchStripeSetupIntent({ provider, owner, signal }).then((res) => { + const parsedRes = StripeSetupIntentSchema.safeParse(res) + + if (!parsedRes.success) { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useStripeSetupIntent - 404 failed to parse', + } satisfies NetworkErrorObject) + } + + return parsedRes.data + }), + ...opts, + }) +} diff --git a/src/services/account/useUpdatePaymentMethod.ts b/src/services/account/useUpdatePaymentMethod.ts new file mode 100644 index 0000000000..9061252071 --- /dev/null +++ b/src/services/account/useUpdatePaymentMethod.ts @@ -0,0 +1,63 @@ +import { useElements, useStripe } from '@stripe/react-stripe-js' +import { StripeCardElement } from '@stripe/stripe-js' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import Api from 'shared/api' + +interface useUpdatePaymentMethodProps { + provider: string + owner: string +} + +interface useUpdatePaymentMethodReturn { + reset: () => void + error: null | Error + isLoading: boolean + mutate: (variables: any, data: any) => void + data: undefined | unknown +} + +function getPathAccountDetails({ provider, owner }: useUpdatePaymentMethodProps) { + return `/${provider}/${owner}/account-details/` +} + +export function useUpdatePaymentMethod({ + provider, + owner, +}: useUpdatePaymentMethodProps): useUpdatePaymentMethodReturn { + const stripe = useStripe() + const elements = useElements() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => { + return stripe! + .confirmSetup({ + elements: elements!, + confirmParams: { + // eslint-disable-next-line camelcase + return_url: 'http://localhost:3000/plan/github/suejung-sentry', + }, + }) + .then((result) => { + if (result.error) return Promise.reject(result.error) + + const accountPath = getPathAccountDetails({ provider, owner }) + const path = `${accountPath}update_payment` + + return Api.patch({ + provider, + path, + body: { + /* eslint-disable-next-line camelcase */ + payment_method: result.paymentMethod.id, + }, + }) + }) + }, + onSuccess: (data) => { + // update the local cache of account details from what the server returns + queryClient.setQueryData(['accountDetails', provider, owner], data) + }, + }) +}