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(checkout-flow): handle credit card functionality #8005

Merged
merged 19 commits into from
Apr 24, 2023
Merged
Show file tree
Hide file tree
Changes from 13 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
2 changes: 1 addition & 1 deletion codegen.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"Comment": "../../database/types/Comment#default as CommentDB",
"Company": "./types/Company#CompanySource",
"CreateImposterTokenPayload": "./types/CreateImposterTokenPayload#CreateImposterTokenPayloadSource",
"CreatePaymentIntentSuccess": "./types/CreatePaymentIntentSuccess#CreatePaymentIntentSuccessSource",
"CreateSetupIntentSuccess": "./types/CreateSetupIntentSuccess#CreateSetupIntentSuccessSource",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

SetupIntent is used for subscriptions whereas createPaymentIntent is used for one-off payments

"Discussion": "../../postgres/queries/generated/getDiscussionsByIdsQuery#IGetDiscussionsByIdsQueryResult",
"JiraRemoteProject": "../types/JiraRemoteProject#JiraRemoteProjectSource",
"UserLogInPayload": "./types/UserLogInPayload#UserLogInPayloadSource",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ import styled from '@emotion/styled'
import {PaymentElement, useStripe, useElements} from '@stripe/react-stripe-js'
import PrimaryButton from '../../../../components/PrimaryButton'
import {PALETTE} from '../../../../styles/paletteV3'
import Confetti from '../../../../components/Confetti'
import UpgradeToTeamTierMutation from '../../../../mutations/UpgradeToTeamTierMutation'
import useAtmosphere from '../../../../hooks/useAtmosphere'
import useMutationProps from '../../../../hooks/useMutationProps'

const ButtonBlock = styled('div')({
display: 'flex',
justifyContent: 'center',
paddingTop: 16,
wrap: 'nowrap',
flexDirection: 'column',
width: '100%'
})

Expand All @@ -20,56 +26,65 @@ const StyledForm = styled('form')({
alignItems: 'space-between'
})

const PaymentWrapper = styled('div')({
height: 160
})

const UpgradeButton = styled(PrimaryButton)<{isDisabled: boolean}>(({isDisabled}) => ({
background: isDisabled ? PALETTE.SLATE_200 : PALETTE.SKY_500,
color: isDisabled ? PALETTE.SLATE_600 : PALETTE.WHITE,
boxShadow: 'none',
marginTop: 16,
width: '100%',
elevation: 0,
'&:hover': {
'&:hover, &:focus': {
Copy link
Contributor

Choose a reason for hiding this comment

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

Just a side note, the "Plans" panel does not have any focus behaviour which makes it impossible to use with just the keyboard

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, that would be nice! I've created an issue to work on that: #8085

boxShadow: 'none',
background: isDisabled ? PALETTE.SLATE_200 : PALETTE.SKY_600
}
}))

export default function BillingForm() {
type Props = {
orgId: string
}

const BillingForm = (props: Props) => {
const {orgId} = props
const stripe = useStripe()
const elements = useElements()
const [isLoading, setIsLoading] = useState(false)
const [isPaymentSuccessful, setIsPaymentSuccessful] = useState(false)
const atmosphere = useAtmosphere()
const {onError} = useMutationProps()

// TODO: implement in: https://github.com/ParabolInc/parabol/issues/7693
// look at: https://stripe.com/docs/payments/quickstart
const handleSubmit = async () => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!stripe || !elements) return
setIsLoading(true)
const {setupIntent, error} = await stripe.confirmSetup({
elements,
redirect: 'if_required'
})
setIsLoading(false)
if (error) return
const {payment_method: paymentMethodId, status} = setupIntent
if (status === 'succeeded' && typeof paymentMethodId === 'string') {
Copy link
Contributor

Choose a reason for hiding this comment

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

-1 What about if not? If I test with a declining card 4000000000000002 or with a 3D Secure card 4000002760003184 and decline, then there is no message at all

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, updated!

By the way, 3D Secure cards won't create subscriptions in this PR. It'll be handled in #7864

setIsPaymentSuccessful(true)
const handleCompleted = () => {}
nickoferrall marked this conversation as resolved.
Show resolved Hide resolved
UpgradeToTeamTierMutation(
atmosphere,
{orgId, paymentMethodId},
{onError, onCompleted: handleCompleted}
)
}
}

if (!stripe || !elements) return null

return (
<StyledForm id='payment-form' onSubmit={handleSubmit}>
<PaymentWrapper>
<PaymentElement
id='payment-element'
options={{
layout: 'tabs',
fields: {
billingDetails: {
address: 'never'
}
}
}}
/>
</PaymentWrapper>
<PaymentElement id='payment-element' options={{layout: 'tabs'}} />
<ButtonBlock>
<UpgradeButton size='medium' isDisabled={isLoading || !stripe || !elements} type={'submit'}>
{'Upgrade'}
</UpgradeButton>
</ButtonBlock>
<Confetti active={isPaymentSuccessful} />
nickoferrall marked this conversation as resolved.
Show resolved Hide resolved
</StyledForm>
)
}

export default BillingForm
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const OrgPlansAndBilling = (props: Props) => {
...OrgPlansAndBillingHeading_organization
...OrgPlans_organization
...BillingLeaders_organization
...PaymentDetails_organization
}
`,
organizationRef
Expand All @@ -28,7 +29,7 @@ const OrgPlansAndBilling = (props: Props) => {
<Suspense fallback={''}>
<OrgPlansAndBillingHeading organizationRef={organization} />
<OrgPlans organizationRef={organization} />
<PaymentDetails />
<PaymentDetails organizationRef={organization} />
<BillingLeaders organizationRef={organization} />
</Suspense>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import styled from '@emotion/styled'
import {PaymentDetails_organization$key} from '../../../../__generated__/PaymentDetails_organization.graphql'
import graphql from 'babel-plugin-relay/macro'
import {useFragment} from 'react-relay'
import {Divider} from '@mui/material'
import {Elements} from '@stripe/react-stripe-js'
import {loadStripe} from '@stripe/stripe-js'
Expand All @@ -7,12 +10,13 @@ import Panel from '../../../../components/Panel/Panel'
import Row from '../../../../components/Row/Row'
import useAtmosphere from '../../../../hooks/useAtmosphere'
import useMutationProps from '../../../../hooks/useMutationProps'
import CreatePaymentIntentMutation from '../../../../mutations/CreatePaymentIntentMutation'
import CreateSetupIntentMutation from '../../../../mutations/CreateSetupIntentMutation'
import {PALETTE} from '../../../../styles/paletteV3'
import {ElementWidth} from '../../../../types/constEnums'
import {CompletedHandler} from '../../../../types/relayMutations'
import {CreatePaymentIntentMutation as TCreatePaymentIntentMutation} from '../../../../__generated__/CreatePaymentIntentMutation.graphql'
import {CreateSetupIntentMutation as TCreateSetupIntentMutation} from '../../../../__generated__/CreateSetupIntentMutation.graphql'
import BillingForm from './BillingForm'
import {MONTHLY_PRICE} from '../../../../utils/constants'

const StyledPanel = styled(Panel)({
maxWidth: ElementWidth.PANEL_WIDTH
Expand Down Expand Up @@ -125,36 +129,44 @@ const stripeElementOptions = {

const stripePromise = loadStripe(window.__ACTION__.stripe)

const PaymentDetails = () => {
type Props = {
organizationRef: PaymentDetails_organization$key
}

const PaymentDetails = (props: Props) => {
const {organizationRef} = props
const [clientSecret, setClientSecret] = useState('')
const atmosphere = useAtmosphere()
const {onError} = useMutationProps()

const organization = useFragment(
graphql`
fragment PaymentDetails_organization on Organization {
id
tier
orgUserCount {
activeUserCount
}
}
`,
organizationRef
)
const {id: orgId, orgUserCount, tier} = organization
const {activeUserCount} = orgUserCount
const price = activeUserCount * MONTHLY_PRICE

useEffect(() => {
const handleCompleted: CompletedHandler<TCreatePaymentIntentMutation['response']> = (res) => {
const {createPaymentIntent} = res
const {clientSecret} = createPaymentIntent
if (tier !== 'starter') return
const handleCompleted: CompletedHandler<TCreateSetupIntentMutation['response']> = (res) => {
const {createSetupIntent} = res
const {clientSecret} = createSetupIntent
if (clientSecret) {
setClientSecret(clientSecret)
}
}

CreatePaymentIntentMutation(atmosphere, {}, {onError, onCompleted: handleCompleted})
CreateSetupIntentMutation(atmosphere, {orgId}, {onError, onCompleted: handleCompleted})
}, [])

// TODO: add functionality in https://github.com/ParabolInc/parabol/issues/7693
// const handleSubmit = async (e: React.FormEvent) => {
// e.preventDefault()
// // if (submitting) return
// // these 3 calls internally call dispatch (or setState), which are asynchronous in nature.
// // To get the current value of `fields`, we have to wait for the component to rerender
// // the useEffect hook above will continue the process if submitting === true

// setDirtyField()
// validateField()
// submitMutation()
// }

if (!clientSecret.length) return null
return (
<StyledPanel label='Credit Card'>
Expand All @@ -169,7 +181,7 @@ const PaymentDetails = () => {
}}
stripe={stripePromise}
>
<BillingForm />
<BillingForm orgId={orgId} />
</Elements>
</Content>
</Plan>
Expand All @@ -183,12 +195,12 @@ const PaymentDetails = () => {
<InfoText>
{'Active users are anyone who uses Parabol within a billing period'}
</InfoText>
<Subtitle>{'27'}</Subtitle>
<Subtitle>{activeUserCount}</Subtitle>
</ActiveUserBlock>
<Divider />
<TotalBlock>
<Subtitle>{'Total'}</Subtitle>
<Subtitle>{'$162.00'}</Subtitle>
<Subtitle>{`$${price}.00`}</Subtitle>
nickoferrall marked this conversation as resolved.
Show resolved Hide resolved
</TotalBlock>
<InfoText>{'All prices are in USD'}</InfoText>
</Content>
Expand Down
34 changes: 0 additions & 34 deletions packages/client/mutations/CreatePaymentIntentMutation.ts

This file was deleted.

34 changes: 34 additions & 0 deletions packages/client/mutations/CreateSetupIntentMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import graphql from 'babel-plugin-relay/macro'
import {commitMutation} from 'react-relay'
import {StandardMutation} from '../types/relayMutations'
import {CreateSetupIntentMutation as TCreateSetupIntentMutation} from '../__generated__/CreateSetupIntentMutation.graphql'

const mutation = graphql`
mutation CreateSetupIntentMutation {
createSetupIntent {
... on ErrorPayload {
error {
message
}
}
... on CreateSetupIntentSuccess {
clientSecret
}
}
}
`

const CreateSetupIntentMutation: StandardMutation<TCreateSetupIntentMutation> = (
atmosphere,
variables,
{onError, onCompleted}
) => {
return commitMutation<TCreateSetupIntentMutation>(atmosphere, {
mutation,
variables,
onCompleted,
onError
})
}

export default CreateSetupIntentMutation
4 changes: 2 additions & 2 deletions packages/client/mutations/UpgradeToTeamTierMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ graphql`
`

const mutation = graphql`
mutation UpgradeToTeamTierMutation($orgId: ID!, $stripeToken: ID!) {
upgradeToTeamTier(orgId: $orgId, stripeToken: $stripeToken) {
mutation UpgradeToTeamTierMutation($orgId: ID!, $stripeToken: ID, $paymentMethodId: ID) {
upgradeToTeamTier(orgId: $orgId, stripeToken: $stripeToken, paymentMethodId: $paymentMethodId) {
error {
message
}
Expand Down
30 changes: 14 additions & 16 deletions packages/server/graphql/mutations/helpers/upgradeToTeamTier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ import setTierForOrgUsers from '../../../utils/setTierForOrgUsers'
import setUserTierForOrgId from '../../../utils/setUserTierForOrgId'
import {getStripeManager} from '../../../utils/stripe'
import {DataLoaderWorker} from '../../graphql'
import getCCFromCustomer from './getCCFromCustomer'

const upgradeToTeamTier = async (
orgId: string,
source: string,
paymentMethodId: string,
email: string,
dataLoader: DataLoaderWorker
) => {
Expand All @@ -20,7 +19,6 @@ const upgradeToTeamTier = async (
const organization = await r.table('Organization').get(orgId).run()
if (!organization) throw new Error('Bad orgId')

const {stripeId, stripeSubscriptionId} = organization
const quantity = await r
.table('OrganizationUser')
.getAll(orgId, {index: 'orgId'})
Expand All @@ -29,18 +27,19 @@ const upgradeToTeamTier = async (
.run()

const manager = getStripeManager()
const customer = stripeId
? await manager.updatePayment(stripeId, source)
: await manager.createCustomer(orgId, email, source)

let subscriptionFields = {}
if (!stripeSubscriptionId) {
const subscription = await manager.createTeamSubscription(customer.id, orgId, quantity)
subscriptionFields = {
periodEnd: fromEpochSeconds(subscription.current_period_end),
periodStart: fromEpochSeconds(subscription.current_period_start),
stripeSubscriptionId: subscription.id
}
const customers = await manager.getCustomersByEmail(email)
const existingCustomer = customers.data.find((customer) => customer.metadata.orgId === orgId)
const customer = existingCustomer ?? (await manager.createCustomer(orgId, email))
const {id: customerId} = customer
await Promise.all([
manager.attachPaymentToCustomer(customerId, paymentMethodId),
manager.updateDefaultPaymentMethod(customerId, paymentMethodId)
])
const subscription = await manager.createTeamSubscription(customer.id, orgId, quantity)
const subscriptionFields = {
periodEnd: fromEpochSeconds(subscription.current_period_end),
periodStart: fromEpochSeconds(subscription.current_period_start),
stripeSubscriptionId: subscription.id
}

await Promise.all([
Expand All @@ -50,7 +49,6 @@ const upgradeToTeamTier = async (
.get(orgId)
.update({
...subscriptionFields,
creditCard: await getCCFromCustomer(customer),
tier: 'team',
stripeId: customer.id,
tierLimitExceededAt: null,
Expand Down
Loading