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
Changes from 1 commit
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
Prev Previous commit
Next Next commit
cleanup
  • Loading branch information
suejung-sentry committed Jan 24, 2025
commit fb74ddef138ce263a5e3a53256962350ed1f9bb3
28 changes: 12 additions & 16 deletions src/pages/PlanPage/PlanPage.jsx → src/pages/PlanPage/PlanPage.tsx
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ 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'
@@ -19,10 +20,8 @@ import PlanBreadcrumb from './PlanBreadcrumb'
import { PlanPageDataQueryOpts } from './queries/PlanPageDataQueryOpts'
import Tabs from './Tabs'


import { StripeAppearance } from '../../stripe'


const CancelPlanPage = lazy(() => import('./subRoutes/CancelPlanPage'))
const CurrentOrgPlan = lazy(() => import('./subRoutes/CurrentOrgPlan'))
const InvoicesPage = lazy(() => import('./subRoutes/InvoicesPage'))
@@ -40,8 +39,13 @@ 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 })
)
@@ -56,15 +60,8 @@ function PlanPage() {
if (config.IS_SELF_HOSTED || !ownerData?.isCurrentUserPartOfOrg) {
return <Redirect to={`/${provider}/${owner}`} />
}

const isAwaitingVerification =
const hasUnverifiedPaymentMethods =
accountDetails?.unverifiedPaymentMethods?.length
// const isAwaitingFirstPaymentMethodVerification =
// !accountDetails?.subscriptionDetail?.defaultPaymentMethod &&
// isAwaitingVerification

// const hasSuccessfulDefaultPaymentMethod =
// accountDetails?.subscriptionDetail?.defaultPaymentMethod

return (
<div className="flex flex-col gap-4">
@@ -80,7 +77,7 @@ function PlanPage() {
>
<PlanProvider>
<PlanBreadcrumb />
{isAwaitingVerification ? (
{hasUnverifiedPaymentMethods ? (
<UnverifiedPaymentMethodAlert
url={
accountDetails?.unverifiedPaymentMethods?.[0]
@@ -117,10 +114,7 @@ function PlanPage() {
)
}

export default PlanPage

// eslint-disable-next-line react/prop-types
const UnverifiedPaymentMethodAlert = ({ url }) => {
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 {}

@@ -142,3 +136,5 @@ const UnverifiedPaymentMethodAlert = ({ url }) => {
</>
)
}

export default PlanPage
Original file line number Diff line number Diff line change
@@ -15,7 +15,6 @@ function PaymentCard({ accountDetails, provider, owner }) {
const subscriptionDetail = accountDetails?.subscriptionDetail
const card = subscriptionDetail?.defaultPaymentMethod?.card
const usBankAccount = subscriptionDetail?.defaultPaymentMethod?.usBankAccount
// const isAwaitingDelayedPaymentMethodVerification = true

let nextBillingDisplayDate = null
if (!subscriptionDetail?.cancelAtPeriodEnd) {
Original file line number Diff line number Diff line change
@@ -40,13 +40,15 @@
mutate: updatePaymentMethod,
isLoading,
error,
reset,
} = useUpdatePaymentMethod({
provider,
owner,
name,
email,
address: stripeAddress(billingDetails) || undefined,
})

async function submit(e: React.FormEvent) {
e.preventDefault()

@@ -67,6 +69,8 @@
})
}

const showError = error && !reset

return (
<form onSubmit={submit} aria-label="form">
<div className={cs('flex flex-col gap-3')}>
@@ -83,7 +87,7 @@
}}
/>
<p className="mt-1 text-ds-primary-red">
{error ? getErrorMessage(error) : null}
{showError ? getErrorMessage(error) : null}
</p>
<div className="mb-8 mt-4 flex gap-1">
<Button
@@ -155,7 +159,7 @@
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

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:
23 changes: 8 additions & 15 deletions src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx
Original file line number Diff line number Diff line change
@@ -40,22 +40,22 @@ function CurrentOrgPlan() {
})
)

const isAwaitingVerification =
accountDetails?.unverifiedPaymentMethods?.length
const isAwaitingFirstPaymentMethodVerification =
// 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 &&
isAwaitingVerification
accountDetails?.unverifiedPaymentMethods?.length

const scheduledPhase = accountDetails?.scheduleDetail?.scheduledPhase
// customer is delinquent until their first payment method is verified
const isDelinquent =
accountDetails?.delinquent && !isAwaitingFirstPaymentMethodVerification
accountDetails?.delinquent && !awaitingInitialPaymentMethodVerification
const scheduleStart = scheduledPhase
? getScheduleStart(scheduledPhase)
: undefined

const shouldRenderBillingDetails =
!isAwaitingFirstPaymentMethodVerification &&
!awaitingInitialPaymentMethodVerification &&
((accountDetails?.planProvider !== 'github' &&
!accountDetails?.rootOrganization) ||
accountDetails?.usesInvoice)
@@ -64,21 +64,14 @@ function CurrentOrgPlan() {

const account = enterpriseDetails?.owner?.account

const hasSuccessfulDefaultPaymentMethod =
accountDetails?.subscriptionDetail?.defaultPaymentMethod

const hideSuccessBanner = isAwaitingVerification
? hasSuccessfulDefaultPaymentMethod
: true

return (
<div className="w-full lg:w-4/5">
{planUpdatedNotification.isCancellation ? (
<InfoAlertCancellation
subscriptionDetail={accountDetails?.subscriptionDetail}
/>
) : null}
{hideSuccessBanner ? <InfoMessageStripeCallback /> : null}
<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">
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">
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'

@@ -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
@@ -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,
@@ -90,22 +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((data) => {
if (accountDetails?.unverifiedPaymentMethods?.length) {
if (
window.confirm(
'You have a payment method awaiting verification. Are you sure you want to proceed with upgrading your plan? This will cancel the existing action.'
)
) {
upgradePlan(data)
}
} else {
upgradePlan(data)
}
})}
onSubmit={onSubmit}
>
<div className="flex flex-col gap-1">
<h3 className="font-semibold">Organization</h3>
@@ -131,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>
)
}
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
Loading
Oops, something went wrong.