Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ACH payment flows #3673

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import config from 'config'

import { SentryRoute } from 'sentry'

import { useAccountDetails } from 'services/account'
import { Provider } from 'shared/api/helpers'
import { Theme, useThemeContext } from 'shared/ThemeContext'
import A from 'ui/A'
import { Alert } from 'ui/Alert'
import LoadingLogo from 'ui/LoadingLogo'

import { PlanProvider } from './context'
Expand All @@ -35,17 +39,29 @@ const Loader = () => (
</div>
)

interface URLParams {
owner: string
provider: Provider
}

function PlanPage() {
const { owner, provider } = useParams()
const { owner, provider } = useParams<URLParams>()
const { data: ownerData } = useSuspenseQueryV5(
PlanPageDataQueryOpts({ owner, provider })
)
const { data: accountDetails } = useAccountDetails({
provider,
owner,
})

const { theme } = useThemeContext()
const isDarkMode = theme !== Theme.LIGHT

if (config.IS_SELF_HOSTED || !ownerData?.isCurrentUserPartOfOrg) {
return <Redirect to={`/${provider}/${owner}`} />
}
const hasUnverifiedPaymentMethods =
accountDetails?.unverifiedPaymentMethods?.length

return (
<div className="flex flex-col gap-4">
Expand All @@ -61,6 +77,14 @@ function PlanPage() {
>
<PlanProvider>
<PlanBreadcrumb />
{hasUnverifiedPaymentMethods ? (
<UnverifiedPaymentMethodAlert
url={
accountDetails?.unverifiedPaymentMethods?.[0]
?.hostedVerificationLink
}
/>
) : null}
<Suspense fallback={<Loader />}>
<Switch>
<SentryRoute path={path} exact>
Expand Down Expand Up @@ -90,4 +114,27 @@ function PlanPage() {
)
}

const UnverifiedPaymentMethodAlert = ({ url }: { url?: string }) => {
return (
<>
<Alert variant={'warning'}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: don't need to wrap that in {}

<Alert.Title>New Payment Method Awaiting Verification</Alert.Title>
<Alert.Description>
Your new payment method requires verification.{' '}
<A
href={url}
isExternal={true}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can just put isExternal

hook="stripe-payment-method-verification"
to={undefined}
>
Click here
</A>{' '}
to complete the verification process.
</Alert.Description>
</Alert>
<br />
</>
)
}

export default PlanPage
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function PaymentCard({ accountDetails, provider, owner }) {
variant="primary"
onClick={() => setIsFormOpen(true)}
>
Set card
Set payment method
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
import { z } from 'zod'

import { AccountDetailsSchema, BillingDetailsSchema } from 'services/account'
import { useUpdatePaymentMethod } from 'services/account/useUpdatePaymentMethod'
import {
MissingAddressError,
MissingEmailError,
MissingNameError,
useUpdatePaymentMethod,
} from 'services/account/useUpdatePaymentMethod'
import { Provider } from 'shared/api/helpers'
import A from 'ui/A'
import Button from 'ui/Button'

interface PaymentMethodFormProps {
Expand Down Expand Up @@ -81,7 +87,7 @@
}}
/>
<p className="mt-1 text-ds-primary-red">
{showError && error?.message}
{showError ? getErrorMessage(error) : null}
</p>
<div className="mb-8 mt-4 flex gap-1">
<Button
Expand Down Expand Up @@ -150,4 +156,27 @@
)
}

export const getErrorMessage = (error: Error): JSX.Element => {
switch (error.message) {
case MissingNameError:
return <span>Missing name, please edit Full Name</span>

Check failure on line 162 in src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentMethodForm.tsx

View workflow job for this annotation

GitHub Actions / Test Runner #2 - Vitest

src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentMethodForm.test.tsx > PaymentMethodForm > when there is an error in the form > renders the error

Error: [vitest] No "MissingNameError" export is defined on the "services/account/useUpdatePaymentMethod" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("services/account/useUpdatePaymentMethod"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ getErrorMessage src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentMethodForm.tsx:162:14 ❯ PaymentMethodForm src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentMethodForm.tsx:98:14 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5
case MissingEmailError:
return <span>Missing email, please edit Email</span>
case MissingAddressError:
return <span>Missing address, please edit Address</span>
default:
return (
<span>
There&apos;s been an error. Please try refreshing your browser, if
this error persists please{' '}
{/* @ts-expect-error ignore until we can convert A component to ts */}
<A to={{ pageName: 'support' }} variant="link">
contact support
</A>
.
</span>
)
}
}

export default PaymentMethodForm
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ describe('CurrentOrgPlan', () => {
)
expect(paymentFailed).toBeInTheDocument()
const contactSupport = await screen.findByText(
'Please try a different card or contact support at support@codecov.io.'
'Please try a different payment method or contact support at support@codecov.io.'
)
expect(contactSupport).toBeInTheDocument()
})
Expand Down
20 changes: 15 additions & 5 deletions src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,25 @@ function CurrentOrgPlan() {
})
)

// awaitingInitialPaymentMethodVerification is true if the
// customer needs to verify a delayed notification payment method
// like ACH for their first subscription
const awaitingInitialPaymentMethodVerification =
!accountDetails?.subscriptionDetail?.defaultPaymentMethod &&
accountDetails?.unverifiedPaymentMethods?.length

const scheduledPhase = accountDetails?.scheduleDetail?.scheduledPhase
const isDelinquent = accountDetails?.delinquent
const isDelinquent =
accountDetails?.delinquent && !awaitingInitialPaymentMethodVerification
const scheduleStart = scheduledPhase
? getScheduleStart(scheduledPhase)
: undefined

const shouldRenderBillingDetails =
(accountDetails?.planProvider !== 'github' &&
!awaitingInitialPaymentMethodVerification &&
((accountDetails?.planProvider !== 'github' &&
!accountDetails?.rootOrganization) ||
accountDetails?.usesInvoice
accountDetails?.usesInvoice)

const planUpdatedNotification = usePlanUpdatedNotification()

Expand All @@ -62,7 +71,7 @@ function CurrentOrgPlan() {
subscriptionDetail={accountDetails?.subscriptionDetail}
/>
) : null}
<InfoMessageStripeCallback />
<InfoMessageStripeCallback accountDetails={accountDetails} />
{isDelinquent ? <DelinquentAlert /> : null}
{planData?.plan ? (
<div className="flex flex-col gap-4 sm:mr-4 sm:flex-initial md:w-2/3 lg:w-3/4">
Expand Down Expand Up @@ -147,7 +156,8 @@ const DelinquentAlert = () => {
<Alert variant={'error'}>
<Alert.Title>Your most recent payment failed</Alert.Title>
<Alert.Description>
Please try a different card or contact support at support@codecov.io.
Please try a different payment method or contact support at
support@codecov.io.
</Alert.Description>
</Alert>
<br />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import qs from 'qs'
import { useLocation } from 'react-router-dom'
import { z } from 'zod'

import Message from 'old_ui/Message'
import { AccountDetailsSchema } from 'services/account'

// Stripe redirects to this page with ?success or ?cancel in the URL
// this component takes care of rendering a message if it is successful
function InfoMessageStripeCallback() {
function InfoMessageStripeCallback({
accountDetails,
}: {
accountDetails?: z.infer<typeof AccountDetailsSchema>
}) {
const urlParams = qs.parse(useLocation().search, {
ignoreQueryPrefix: true,
})
const isAwaitingVerification =
accountDetails?.unverifiedPaymentMethods?.length

if ('success' in urlParams)
if ('success' in urlParams && !isAwaitingVerification)
return (
<div className="col-start-1 col-end-13 mb-4">
<Message variant="success">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useParams } from 'react-router-dom'

Expand All @@ -23,6 +23,7 @@ import { useUpgradeControls } from './hooks'
import PlanTypeOptions from './PlanTypeOptions'
import UpdateBlurb from './UpdateBlurb/UpdateBlurb'
import UpdateButton from './UpdateButton'
import UpgradeFormModal from './UpgradeFormModal'

type URLParams = {
provider: Provider
Expand All @@ -45,6 +46,9 @@ function UpgradeForm({ selectedPlan, setSelectedPlan }: UpgradeFormProps) {
const { data: plans } = useAvailablePlans({ provider, owner })
const { data: planData } = usePlanData({ owner, provider })
const { upgradePlan } = useUpgradeControls()
const [showModal, setShowModal] = useState(false)
const [formData, setFormData] = useState<UpgradeFormFields>()
const [isUpgrading, setIsUpgrading] = useState(false)
const isSentryUpgrade = canApplySentryUpgrade({
isEnterprisePlan: planData?.plan?.isEnterprisePlan,
plans,
Expand Down Expand Up @@ -90,10 +94,20 @@ function UpgradeForm({ selectedPlan, setSelectedPlan }: UpgradeFormProps) {
trigger('seats')
}, [newPlan, trigger])

const onSubmit = handleSubmit((data) => {
if (accountDetails?.unverifiedPaymentMethods?.length) {
setFormData(data)
setShowModal(true)
} else {
setIsUpgrading(true)
upgradePlan(data)
}
})

return (
<form
className="flex flex-col gap-6 border p-4 text-ds-gray-default md:w-2/3"
onSubmit={handleSubmit(upgradePlan)}
onSubmit={onSubmit}
>
<div className="flex flex-col gap-1">
<h3 className="font-semibold">Organization</h3>
Expand All @@ -119,6 +133,21 @@ function UpgradeForm({ selectedPlan, setSelectedPlan }: UpgradeFormProps) {
nextBillingDate={getNextBillingDate(accountDetails)!}
/>
<UpdateButton isValid={isValid} newPlan={newPlan} seats={seats} />
{showModal && formData && (
<UpgradeFormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onConfirm={() => {
setIsUpgrading(true)
upgradePlan(formData)
}}
url={
accountDetails?.unverifiedPaymentMethods?.[0]
?.hostedVerificationLink || ''
}
isUpgrading={isUpgrading}
/>
)}
</form>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import A from 'ui/A'
import Button from 'ui/Button'
import Icon from 'ui/Icon'
import Modal from 'ui/Modal'

interface UpgradeModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
url: string
isUpgrading?: boolean
}

const UpgradeFormModal = ({
isOpen,
onClose,
onConfirm,
url,
isUpgrading = false,
}: UpgradeModalProps) => (
<Modal
isOpen={isOpen}
onClose={onClose}
title={
<p className="flex items-center gap-2 text-base">
<Icon
name="exclamationTriangle"
size="sm"
className="fill-ds-primary-yellow"
/>
Incomplete Plan Upgrade
</p>
}
body={
<div className="flex flex-col gap-4">
<div>
You have an incomplete plan upgrade that is awaiting payment
verification{' '}
<A
href={url}
isExternal
hook={'verify-payment-method'}
to={undefined}
>
here
</A>
.
</div>
<p>
Are you sure you wish to abandon the pending upgrade and start a new
one?
</p>
</div>
}
footer={
<div className="flex gap-2">
<Button hook="cancel-upgrade" onClick={onClose} disabled={isUpgrading}>
Cancel
</Button>
<Button
hook="confirm-upgrade"
variant="primary"
onClick={onConfirm}
disabled={isUpgrading}
>
{isUpgrading ? 'Processing...' : 'Yes, Start New Upgrade'}
</Button>
</div>
}
/>
)

export default UpgradeFormModal
8 changes: 8 additions & 0 deletions src/services/account/useAccountDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ export const AccountDetailsSchema = z.object({
studentCount: z.number(),
subscriptionDetail: SubscriptionDetailSchema,
usesInvoice: z.boolean(),
unverifiedPaymentMethods: z
.array(
z.object({
paymentMethodId: z.string(),
hostedVerificationLink: z.string(),
})
)
.nullable(),
})

export interface UseAccountDetailsArgs {
Expand Down
5 changes: 5 additions & 0 deletions src/services/account/useUpdatePaymentMethod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,8 @@ export function useUpdatePaymentMethod({
},
})
}

// Errors from Stripe api confirmSetup() - unfortunately seems to just be by text, no error codes
export const MissingNameError = `You specified "never" for fields.billing_details.name when creating the payment Element, but did not pass confirmParams.payment_method_data.billing_details.name when calling stripe.confirmSetup(). If you opt out of collecting data via the payment Element using the fields option, the data must be passed in when calling stripe.confirmSetup().`
export const MissingEmailError = `You specified "never" for fields.billing_details.email when creating the payment Element, but did not pass confirmParams.payment_method_data.billing_details.email when calling stripe.confirmSetup(). If you opt out of collecting data via the payment Element using the fields option, the data must be passed in when calling stripe.confirmSetup().`
export const MissingAddressError = `You specified "never" for fields.billing_details.address when creating the payment Element, but did not pass confirmParams.payment_method_data.billing_details.address when calling stripe.confirmSetup(). If you opt out of collecting data via the payment Element using the fields option, the data must be passed in when calling stripe.confirmSetup().`
Loading