Skip to content

Commit 8879e3e

Browse files
feat: Add ACH microdeposits handling (#3693)
1 parent e9ca127 commit 8879e3e

15 files changed

+858
-63
lines changed

src/pages/PlanPage/PlanPage.test.jsx src/pages/PlanPage/PlanPage.test.tsx

+53-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import config from 'config'
1313

1414
import { ThemeContextProvider } from 'shared/ThemeContext'
1515

16+
import { Location } from 'history'
17+
import { UnverifiedPaymentMethodSchema } from 'services/account'
18+
import { z } from 'zod'
1619
import PlanPage from './PlanPage'
1720

1821
vi.mock('config')
@@ -40,9 +43,9 @@ const queryClientV5 = new QueryClientV5({
4043
defaultOptions: { queries: { retry: false } },
4144
})
4245

43-
let testLocation
46+
let testLocation: Location<unknown>
4447
const wrapper =
45-
(initialEntries = '') =>
48+
(initialEntries = ''): React.FC<React.PropsWithChildren> =>
4649
({ children }) => (
4750
<QueryClientProviderV5 client={queryClientV5}>
4851
<QueryClientProvider client={queryClient}>
@@ -79,7 +82,13 @@ afterAll(() => {
7982

8083
describe('PlanPage', () => {
8184
function setup(
82-
{ owner, isSelfHosted = false } = {
85+
{
86+
owner,
87+
isSelfHosted = false,
88+
unverifiedPaymentMethods = [] as z.infer<
89+
typeof UnverifiedPaymentMethodSchema
90+
>[],
91+
} = {
8392
owner: {
8493
username: 'codecov',
8594
isCurrentUserPartOfOrg: true,
@@ -92,6 +101,17 @@ describe('PlanPage', () => {
92101
server.use(
93102
graphql.query('PlanPageData', () => {
94103
return HttpResponse.json({ data: { owner } })
104+
}),
105+
graphql.query('UnverifiedPaymentMethods', () => {
106+
return HttpResponse.json({
107+
data: {
108+
owner: {
109+
billing: {
110+
unverifiedPaymentMethods,
111+
},
112+
},
113+
},
114+
})
95115
})
96116
)
97117
}
@@ -102,7 +122,7 @@ describe('PlanPage', () => {
102122
owner: {
103123
username: 'codecov',
104124
isCurrentUserPartOfOrg: false,
105-
numberOfUploads: null,
125+
numberOfUploads: 0,
106126
},
107127
})
108128
})
@@ -120,7 +140,7 @@ describe('PlanPage', () => {
120140
owner: {
121141
username: 'codecov',
122142
isCurrentUserPartOfOrg: false,
123-
numberOfUploads: null,
143+
numberOfUploads: 0,
124144
},
125145
})
126146
})
@@ -149,6 +169,34 @@ describe('PlanPage', () => {
149169
const tabs = await screen.findByText(/Tabs/)
150170
expect(tabs).toBeInTheDocument()
151171
})
172+
173+
describe('when there are unverified payment methods', () => {
174+
beforeEach(() => {
175+
setup({
176+
owner: {
177+
username: 'codecov',
178+
isCurrentUserPartOfOrg: true,
179+
numberOfUploads: 30,
180+
},
181+
unverifiedPaymentMethods: [
182+
{
183+
paymentMethodId: 'pm_123',
184+
hostedVerificationUrl: 'https://verify.stripe.com',
185+
},
186+
],
187+
})
188+
})
189+
190+
it('renders unverified payment method alert', async () => {
191+
render(<PlanPage />, { wrapper: wrapper('/plan/gh/codecov') })
192+
193+
const alert = await screen.findByText(/Verify Your New Payment Method/)
194+
expect(alert).toBeInTheDocument()
195+
196+
const link = screen.getByText('Click here')
197+
expect(link).toHaveAttribute('href', 'https://verify.stripe.com')
198+
})
199+
})
152200
})
153201

154202
describe('testing routes', () => {

src/pages/PlanPage/PlanPage.jsx src/pages/PlanPage/PlanPage.tsx

+44-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import config from 'config'
88

99
import { SentryRoute } from 'sentry'
1010

11+
import { useUnverifiedPaymentMethods } from 'services/account/useUnverifiedPaymentMethods'
12+
import { Provider } from 'shared/api/helpers'
1113
import { Theme, useThemeContext } from 'shared/ThemeContext'
14+
import A from 'ui/A'
15+
import { Alert } from 'ui/Alert'
1216
import LoadingLogo from 'ui/LoadingLogo'
1317

1418
import { PlanProvider } from './context'
@@ -35,11 +39,21 @@ const Loader = () => (
3539
</div>
3640
)
3741

42+
interface URLParams {
43+
owner: string
44+
provider: Provider
45+
}
46+
3847
function PlanPage() {
39-
const { owner, provider } = useParams()
48+
const { owner, provider } = useParams<URLParams>()
4049
const { data: ownerData } = useSuspenseQueryV5(
4150
PlanPageDataQueryOpts({ owner, provider })
4251
)
52+
const { data: unverifiedPaymentMethods } = useUnverifiedPaymentMethods({
53+
provider,
54+
owner,
55+
})
56+
4357
const { theme } = useThemeContext()
4458
const isDarkMode = theme !== Theme.LIGHT
4559

@@ -61,6 +75,11 @@ function PlanPage() {
6175
>
6276
<PlanProvider>
6377
<PlanBreadcrumb />
78+
{unverifiedPaymentMethods && unverifiedPaymentMethods.length > 0 ? (
79+
<UnverifiedPaymentMethodAlert
80+
url={unverifiedPaymentMethods[0]?.hostedVerificationUrl}
81+
/>
82+
) : null}
6483
<Suspense fallback={<Loader />}>
6584
<Switch>
6685
<SentryRoute path={path} exact>
@@ -90,4 +109,28 @@ function PlanPage() {
90109
)
91110
}
92111

112+
const UnverifiedPaymentMethodAlert = ({ url }: { url?: string | null }) => {
113+
return (
114+
<>
115+
<Alert variant="warning">
116+
<Alert.Title>Verify Your New Payment Method</Alert.Title>
117+
<Alert.Description>
118+
Your new payment method needs to be verified.{' '}
119+
<A
120+
href={url}
121+
isExternal
122+
hook="stripe-payment-method-verification"
123+
to={undefined}
124+
>
125+
Click here
126+
</A>{' '}
127+
to complete the process. The verification code may take around 2 days
128+
to appear on your bank statement.
129+
</Alert.Description>
130+
</Alert>
131+
<br />
132+
</>
133+
)
134+
}
135+
93136
export default PlanPage

src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.test.tsx

+9-26
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import { MemoryRouter, Route } from 'react-router-dom'
1212
import { z } from 'zod'
1313

1414
import { PlanUpdatedPlanNotificationContext } from 'pages/PlanPage/context'
15-
import { AccountDetailsSchema, TrialStatuses } from 'services/account'
16-
import { BillingRate, Plans } from 'shared/utils/billing'
15+
import { AccountDetailsSchema } from 'services/account'
16+
import { Plans } from 'shared/utils/billing'
1717
import { AlertOptions, type AlertOptionsType } from 'ui/Alert'
1818

1919
import CurrentOrgPlan from './CurrentOrgPlan'
@@ -36,28 +36,6 @@ const mockNoEnterpriseAccount = {
3636
},
3737
}
3838

39-
const mockPlanDataResponse = {
40-
baseUnitPrice: 10,
41-
benefits: [],
42-
billingRate: BillingRate.MONTHLY,
43-
marketingName: 'some-name',
44-
monthlyUploadLimit: 123,
45-
value: Plans.USERS_PR_INAPPM,
46-
trialStatus: TrialStatuses.NOT_STARTED,
47-
trialStartDate: '',
48-
trialEndDate: '',
49-
trialTotalDays: 0,
50-
pretrialUsersCount: 0,
51-
planUserCount: 1,
52-
hasSeatsLeft: true,
53-
isEnterprisePlan: false,
54-
isFreePlan: false,
55-
isProPlan: false,
56-
isSentryPlan: false,
57-
isTeamPlan: false,
58-
isTrialPlan: false,
59-
}
60-
6139
const mockEnterpriseAccountDetailsNinetyPercent = {
6240
owner: {
6341
account: {
@@ -170,10 +148,15 @@ describe('CurrentOrgPlan', () => {
170148
graphql.query('EnterpriseAccountDetails', () => {
171149
return HttpResponse.json({ data: enterpriseAccountDetails })
172150
}),
173-
graphql.query('GetPlanData', () => {
151+
graphql.query('CurrentOrgPlanPageData', () => {
174152
return HttpResponse.json({
175153
data: {
176-
owner: { hasPrivateRepos: true, plan: { ...mockPlanDataResponse } },
154+
owner: {
155+
plan: { value: Plans.USERS_PR_INAPPM },
156+
billing: {
157+
unverifiedPaymentMethods: [],
158+
},
159+
},
177160
},
178161
})
179162
}),

src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx

+21-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useSuspenseQuery as useSuspenseQueryV5 } from '@tanstack/react-queryV5'
22
import { useParams } from 'react-router-dom'
33

44
import { usePlanUpdatedNotification } from 'pages/PlanPage/context'
5-
import { useAccountDetails, usePlanData } from 'services/account'
5+
import { useAccountDetails, useCurrentOrgPlanPageData } from 'services/account'
66
import { Provider } from 'shared/api/helpers'
77
import { getScheduleStart } from 'shared/plan/ScheduledPlanDetails/ScheduledPlanDetails'
88
import A from 'ui/A'
@@ -28,7 +28,7 @@ function CurrentOrgPlan() {
2828
owner,
2929
})
3030

31-
const { data: planData } = usePlanData({
31+
const { data } = useCurrentOrgPlanPageData({
3232
provider,
3333
owner,
3434
})
@@ -40,16 +40,28 @@ function CurrentOrgPlan() {
4040
})
4141
)
4242

43+
const hasUnverifiedPaymentMethods =
44+
!!data?.billing?.unverifiedPaymentMethods?.length
45+
46+
// awaitingInitialPaymentMethodVerification is true if the
47+
// customer needs to verify a delayed notification payment method
48+
// like ACH for their first subscription
49+
const awaitingInitialPaymentMethodVerification =
50+
!accountDetails?.subscriptionDetail?.defaultPaymentMethod &&
51+
hasUnverifiedPaymentMethods
52+
4353
const scheduledPhase = accountDetails?.scheduleDetail?.scheduledPhase
44-
const isDelinquent = accountDetails?.delinquent
54+
const isDelinquent =
55+
accountDetails?.delinquent && !awaitingInitialPaymentMethodVerification
4556
const scheduleStart = scheduledPhase
4657
? getScheduleStart(scheduledPhase)
4758
: undefined
4859

4960
const shouldRenderBillingDetails =
50-
(accountDetails?.planProvider !== 'github' &&
61+
!awaitingInitialPaymentMethodVerification &&
62+
((accountDetails?.planProvider !== 'github' &&
5163
!accountDetails?.rootOrganization) ||
52-
accountDetails?.usesInvoice
64+
accountDetails?.usesInvoice)
5365

5466
const planUpdatedNotification = usePlanUpdatedNotification()
5567

@@ -62,9 +74,11 @@ function CurrentOrgPlan() {
6274
subscriptionDetail={accountDetails?.subscriptionDetail}
6375
/>
6476
) : null}
65-
<InfoMessageStripeCallback />
77+
<InfoMessageStripeCallback
78+
hasUnverifiedPaymentMethods={hasUnverifiedPaymentMethods}
79+
/>
6680
{isDelinquent ? <DelinquentAlert /> : null}
67-
{planData?.plan ? (
81+
{data?.plan ? (
6882
<div className="flex flex-col gap-4 sm:mr-4 sm:flex-initial md:w-2/3 lg:w-3/4">
6983
{planUpdatedNotification.alertOption &&
7084
!planUpdatedNotification.isCancellation ? (

src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.test.tsx

+25-6
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ const wrapper =
1212

1313
describe('InfoMessageStripeCallback', () => {
1414
describe('when rendering without success or cancel in the url', () => {
15-
const { container } = render(<InfoMessageStripeCallback />, {
16-
wrapper: wrapper('/account/gh/codecov'),
17-
})
15+
const { container } = render(
16+
<InfoMessageStripeCallback hasUnverifiedPaymentMethods={false} />,
17+
{
18+
wrapper: wrapper('/account/gh/codecov'),
19+
}
20+
)
1821

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

2427
describe('when rendering with success in the url', () => {
2528
it('renders a success message', async () => {
26-
render(<InfoMessageStripeCallback />, {
27-
wrapper: wrapper('/account/gh/codecov?success'),
28-
})
29+
render(
30+
<InfoMessageStripeCallback hasUnverifiedPaymentMethods={false} />,
31+
{
32+
wrapper: wrapper('/account/gh/codecov?success'),
33+
}
34+
)
2935

3036
await expect(
3137
screen.getByText(/Subscription Update Successful/)
3238
).toBeInTheDocument()
3339
})
3440
})
41+
42+
describe('when hasUnverifiedPaymentMethods is true', () => {
43+
it('does not enders a success message even at ?success', async () => {
44+
const { container } = render(
45+
<InfoMessageStripeCallback hasUnverifiedPaymentMethods={true} />,
46+
{
47+
wrapper: wrapper('/account/gh/codecov?success'),
48+
}
49+
)
50+
51+
expect(container).toBeEmptyDOMElement()
52+
})
53+
})
3554
})

src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.tsx

+6-2
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import Message from 'old_ui/Message'
55

66
// Stripe redirects to this page with ?success or ?cancel in the URL
77
// this component takes care of rendering a message if it is successful
8-
function InfoMessageStripeCallback() {
8+
function InfoMessageStripeCallback({
9+
hasUnverifiedPaymentMethods,
10+
}: {
11+
hasUnverifiedPaymentMethods: boolean
12+
}) {
913
const urlParams = qs.parse(useLocation().search, {
1014
ignoreQueryPrefix: true,
1115
})
1216

13-
if ('success' in urlParams)
17+
if ('success' in urlParams && !hasUnverifiedPaymentMethods)
1418
return (
1519
<div className="col-start-1 col-end-13 mb-4">
1620
<Message variant="success">

0 commit comments

Comments
 (0)