Skip to content

Commit

Permalink
feat: Add ACH microdeposits handling (#3693)
Browse files Browse the repository at this point in the history
  • Loading branch information
suejung-sentry authored Jan 31, 2025
1 parent e9ca127 commit 8879e3e
Show file tree
Hide file tree
Showing 15 changed files with 858 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import config from 'config'

import { ThemeContextProvider } from 'shared/ThemeContext'

import { Location } from 'history'
import { UnverifiedPaymentMethodSchema } from 'services/account'
import { z } from 'zod'
import PlanPage from './PlanPage'

vi.mock('config')
Expand Down Expand Up @@ -40,9 +43,9 @@ const queryClientV5 = new QueryClientV5({
defaultOptions: { queries: { retry: false } },
})

let testLocation
let testLocation: Location<unknown>
const wrapper =
(initialEntries = '') =>
(initialEntries = ''): React.FC<React.PropsWithChildren> =>
({ children }) => (
<QueryClientProviderV5 client={queryClientV5}>
<QueryClientProvider client={queryClient}>
Expand Down Expand Up @@ -79,7 +82,13 @@ afterAll(() => {

describe('PlanPage', () => {
function setup(
{ owner, isSelfHosted = false } = {
{
owner,
isSelfHosted = false,
unverifiedPaymentMethods = [] as z.infer<
typeof UnverifiedPaymentMethodSchema
>[],
} = {
owner: {
username: 'codecov',
isCurrentUserPartOfOrg: true,
Expand All @@ -92,6 +101,17 @@ describe('PlanPage', () => {
server.use(
graphql.query('PlanPageData', () => {
return HttpResponse.json({ data: { owner } })
}),
graphql.query('UnverifiedPaymentMethods', () => {
return HttpResponse.json({
data: {
owner: {
billing: {
unverifiedPaymentMethods,
},
},
},
})
})
)
}
Expand All @@ -102,7 +122,7 @@ describe('PlanPage', () => {
owner: {
username: 'codecov',
isCurrentUserPartOfOrg: false,
numberOfUploads: null,
numberOfUploads: 0,
},
})
})
Expand All @@ -120,7 +140,7 @@ describe('PlanPage', () => {
owner: {
username: 'codecov',
isCurrentUserPartOfOrg: false,
numberOfUploads: null,
numberOfUploads: 0,
},
})
})
Expand Down Expand Up @@ -149,6 +169,34 @@ describe('PlanPage', () => {
const tabs = await screen.findByText(/Tabs/)
expect(tabs).toBeInTheDocument()
})

describe('when there are unverified payment methods', () => {
beforeEach(() => {
setup({
owner: {
username: 'codecov',
isCurrentUserPartOfOrg: true,
numberOfUploads: 30,
},
unverifiedPaymentMethods: [
{
paymentMethodId: 'pm_123',
hostedVerificationUrl: 'https://verify.stripe.com',
},
],
})
})

it('renders unverified payment method alert', async () => {
render(<PlanPage />, { wrapper: wrapper('/plan/gh/codecov') })

const alert = await screen.findByText(/Verify Your New Payment Method/)
expect(alert).toBeInTheDocument()

const link = screen.getByText('Click here')
expect(link).toHaveAttribute('href', 'https://verify.stripe.com')
})
})
})

describe('testing routes', () => {
Expand Down
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 { useUnverifiedPaymentMethods } from 'services/account/useUnverifiedPaymentMethods'
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,11 +39,21 @@ 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: unverifiedPaymentMethods } = useUnverifiedPaymentMethods({
provider,
owner,
})

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

Expand All @@ -61,6 +75,11 @@ function PlanPage() {
>
<PlanProvider>
<PlanBreadcrumb />
{unverifiedPaymentMethods && unverifiedPaymentMethods.length > 0 ? (
<UnverifiedPaymentMethodAlert
url={unverifiedPaymentMethods[0]?.hostedVerificationUrl}
/>
) : null}
<Suspense fallback={<Loader />}>
<Switch>
<SentryRoute path={path} exact>
Expand Down Expand Up @@ -90,4 +109,28 @@ function PlanPage() {
)
}

const UnverifiedPaymentMethodAlert = ({ url }: { url?: string | null }) => {
return (
<>
<Alert variant="warning">
<Alert.Title>Verify Your New Payment Method</Alert.Title>
<Alert.Description>
Your new payment method needs to be verified.{' '}
<A
href={url}
isExternal
hook="stripe-payment-method-verification"
to={undefined}
>
Click here
</A>{' '}
to complete the process. The verification code may take around 2 days
to appear on your bank statement.
</Alert.Description>
</Alert>
<br />
</>
)
}

export default PlanPage
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { MemoryRouter, Route } from 'react-router-dom'
import { z } from 'zod'

import { PlanUpdatedPlanNotificationContext } from 'pages/PlanPage/context'
import { AccountDetailsSchema, TrialStatuses } from 'services/account'
import { BillingRate, Plans } from 'shared/utils/billing'
import { AccountDetailsSchema } from 'services/account'
import { Plans } from 'shared/utils/billing'
import { AlertOptions, type AlertOptionsType } from 'ui/Alert'

import CurrentOrgPlan from './CurrentOrgPlan'
Expand All @@ -36,28 +36,6 @@ const mockNoEnterpriseAccount = {
},
}

const mockPlanDataResponse = {
baseUnitPrice: 10,
benefits: [],
billingRate: BillingRate.MONTHLY,
marketingName: 'some-name',
monthlyUploadLimit: 123,
value: Plans.USERS_PR_INAPPM,
trialStatus: TrialStatuses.NOT_STARTED,
trialStartDate: '',
trialEndDate: '',
trialTotalDays: 0,
pretrialUsersCount: 0,
planUserCount: 1,
hasSeatsLeft: true,
isEnterprisePlan: false,
isFreePlan: false,
isProPlan: false,
isSentryPlan: false,
isTeamPlan: false,
isTrialPlan: false,
}

const mockEnterpriseAccountDetailsNinetyPercent = {
owner: {
account: {
Expand Down Expand Up @@ -170,10 +148,15 @@ describe('CurrentOrgPlan', () => {
graphql.query('EnterpriseAccountDetails', () => {
return HttpResponse.json({ data: enterpriseAccountDetails })
}),
graphql.query('GetPlanData', () => {
graphql.query('CurrentOrgPlanPageData', () => {
return HttpResponse.json({
data: {
owner: { hasPrivateRepos: true, plan: { ...mockPlanDataResponse } },
owner: {
plan: { value: Plans.USERS_PR_INAPPM },
billing: {
unverifiedPaymentMethods: [],
},
},
},
})
}),
Expand Down
28 changes: 21 additions & 7 deletions src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useSuspenseQuery as useSuspenseQueryV5 } from '@tanstack/react-queryV5'
import { useParams } from 'react-router-dom'

import { usePlanUpdatedNotification } from 'pages/PlanPage/context'
import { useAccountDetails, usePlanData } from 'services/account'
import { useAccountDetails, useCurrentOrgPlanPageData } from 'services/account'
import { Provider } from 'shared/api/helpers'
import { getScheduleStart } from 'shared/plan/ScheduledPlanDetails/ScheduledPlanDetails'
import A from 'ui/A'
Expand All @@ -28,7 +28,7 @@ function CurrentOrgPlan() {
owner,
})

const { data: planData } = usePlanData({
const { data } = useCurrentOrgPlanPageData({
provider,
owner,
})
Expand All @@ -40,16 +40,28 @@ function CurrentOrgPlan() {
})
)

const hasUnverifiedPaymentMethods =
!!data?.billing?.unverifiedPaymentMethods?.length

// 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 &&
hasUnverifiedPaymentMethods

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,9 +74,11 @@ function CurrentOrgPlan() {
subscriptionDetail={accountDetails?.subscriptionDetail}
/>
) : null}
<InfoMessageStripeCallback />
<InfoMessageStripeCallback
hasUnverifiedPaymentMethods={hasUnverifiedPaymentMethods}
/>
{isDelinquent ? <DelinquentAlert /> : null}
{planData?.plan ? (
{data?.plan ? (
<div className="flex flex-col gap-4 sm:mr-4 sm:flex-initial md:w-2/3 lg:w-3/4">
{planUpdatedNotification.alertOption &&
!planUpdatedNotification.isCancellation ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ const wrapper =

describe('InfoMessageStripeCallback', () => {
describe('when rendering without success or cancel in the url', () => {
const { container } = render(<InfoMessageStripeCallback />, {
wrapper: wrapper('/account/gh/codecov'),
})
const { container } = render(
<InfoMessageStripeCallback hasUnverifiedPaymentMethods={false} />,
{
wrapper: wrapper('/account/gh/codecov'),
}
)

it('doesnt render anything', () => {
expect(container).toBeEmptyDOMElement()
Expand All @@ -23,13 +26,29 @@ describe('InfoMessageStripeCallback', () => {

describe('when rendering with success in the url', () => {
it('renders a success message', async () => {
render(<InfoMessageStripeCallback />, {
wrapper: wrapper('/account/gh/codecov?success'),
})
render(
<InfoMessageStripeCallback hasUnverifiedPaymentMethods={false} />,
{
wrapper: wrapper('/account/gh/codecov?success'),
}
)

await expect(
screen.getByText(/Subscription Update Successful/)
).toBeInTheDocument()
})
})

describe('when hasUnverifiedPaymentMethods is true', () => {
it('does not enders a success message even at ?success', async () => {
const { container } = render(
<InfoMessageStripeCallback hasUnverifiedPaymentMethods={true} />,
{
wrapper: wrapper('/account/gh/codecov?success'),
}
)

expect(container).toBeEmptyDOMElement()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import Message from 'old_ui/Message'

// 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({
hasUnverifiedPaymentMethods,
}: {
hasUnverifiedPaymentMethods: boolean
}) {
const urlParams = qs.parse(useLocation().search, {
ignoreQueryPrefix: true,
})

if ('success' in urlParams)
if ('success' in urlParams && !hasUnverifiedPaymentMethods)
return (
<div className="col-start-1 col-end-13 mb-4">
<Message variant="success">
Expand Down
Loading

0 comments on commit 8879e3e

Please sign in to comment.