Skip to content

Commit

Permalink
Extract <StripeForm /> (#1346)
Browse files Browse the repository at this point in the history
* Extract <StripeForm />

* Handle server-side errors more gracefully

Co-authored-by: Jeremy Walker <jez.walker@gmail.com>
  • Loading branch information
kntsoriano and iHiD authored Aug 2, 2021
1 parent 3c2dc9b commit b636054
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 40 deletions.
3 changes: 3 additions & 0 deletions app/commands/donations/payment_intent/create.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
module Donations
class PaymentIntentError < RuntimeError
end

module PaymentIntent
class Create
include Mandate
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/api/donations/payment_intents_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ def create
client_secret: payment_intent.client_secret
}
}
rescue Stripe::InvalidRequestError => e
# React currently can't handle this being
# anything other than a 200
render json: {
error: e.message
}, status: :ok
end

def succeeded
Expand Down
63 changes: 56 additions & 7 deletions app/javascript/components/donations/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React, { useState, createContext } from 'react'
import React, { useState, createContext, useCallback, useMemo } from 'react'
import { PaymentIntentType } from './StripeForm'
import { Tab, TabContext } from '../common/Tab'
import { TransactionForm } from './TransactionForm'
import { ExistingSubscriptionNotice } from './ExistingSubscriptionNotice'
import { ExercismStripeElements } from './donation-form/ExercismStripeElements'
import { StripeForm } from './StripeForm'

const TabsContext = createContext<TabContext>({
current: 'subscription',
Expand All @@ -13,6 +15,9 @@ type Links = {
settings: string
}

const PAYMENT_DEFAULT_AMOUNT_IN_DOLLARS = 32
const SUBSCRIPTION_DEFAULT_AMOUNT_IN_DOLLARS = 32

export const Form = ({
existingSubscriptionAmountinDollars,
onSuccess,
Expand All @@ -22,10 +27,49 @@ export const Form = ({
onSuccess: (type: PaymentIntentType, amountInDollars: number) => void
links: Links
}): JSX.Element => {
const [amountInDollars, setAmountInDollars] = useState({
subscription: SUBSCRIPTION_DEFAULT_AMOUNT_IN_DOLLARS,
payment: PAYMENT_DEFAULT_AMOUNT_IN_DOLLARS,
})
const [transactionType, setTransactionType] = useState<PaymentIntentType>(
existingSubscriptionAmountinDollars ? 'payment' : 'subscription'
)

const handleAmountChange = useCallback(
(transactionType: PaymentIntentType) => {
return (value: number) => {
switch (transactionType) {
case 'subscription':
setAmountInDollars({
...amountInDollars,
subscription: isNaN(value)
? SUBSCRIPTION_DEFAULT_AMOUNT_IN_DOLLARS
: value,
})

break
case 'payment':
setAmountInDollars({
...amountInDollars,
payment: isNaN(value) ? PAYMENT_DEFAULT_AMOUNT_IN_DOLLARS : value,
})

break
}
}
},
[amountInDollars]
)

const currentAmountInDollars = useMemo(() => {
switch (transactionType) {
case 'payment':
return amountInDollars.payment
case 'subscription':
return amountInDollars.subscription
}
}, [amountInDollars, transactionType])

return (
<TabsContext.Provider
value={{
Expand All @@ -45,10 +89,9 @@ export const Form = ({
<div className="--content">
<Tab.Panel id="subscription" context={TabsContext}>
<TransactionForm
transactionType="subscription"
defaultAmountInDollars={32}
amountInDollars={amountInDollars.subscription}
onAmountChange={handleAmountChange('subscription')}
presetAmountsInDollars={[16, 32, 64, 128]}
onSuccess={onSuccess}
>
{existingSubscriptionAmountinDollars != null ? (
<ExistingSubscriptionNotice
Expand All @@ -61,12 +104,18 @@ export const Form = ({
</Tab.Panel>
<Tab.Panel id="payment" context={TabsContext}>
<TransactionForm
transactionType="payment"
defaultAmountInDollars={32}
amountInDollars={amountInDollars.payment}
onAmountChange={handleAmountChange('payment')}
presetAmountsInDollars={[32, 128, 256, 512]}
onSuccess={onSuccess}
/>
</Tab.Panel>
<ExercismStripeElements>
<StripeForm
paymentIntentType={transactionType}
amountInDollars={currentAmountInDollars}
onSuccess={onSuccess}
/>
</ExercismStripeElements>
</div>
</div>
</TabsContext.Provider>
Expand Down
16 changes: 14 additions & 2 deletions app/javascript/components/donations/StripeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ export function StripeForm({
type: paymentIntentType,
amount_in_dollars: amountInDollars,
}),
}).then((data: any) => data.paymentIntent)
}).then((data: any) => {
if (data.error) {
setError(`Payment failed with error: ${data.error}`)
return null
}
return data.paymentIntent
})
}, [paymentIntentType, amountInDollars])

const handleSubmit = async (event: React.ChangeEvent<HTMLFormElement>) => {
Expand All @@ -118,6 +124,12 @@ export function StripeForm({
setProcessing(true)

getPaymentRequest().then(async (paymentIntent: PaymentIntent) => {
// If we've failed to get a payment intent get out of here
if (paymentIntent === undefined || paymentIntent === null) {
setProcessing(false)
return
}

// Get a reference to a mounted CardElement. Elements knows how
// to find the CardElement because there can only ever be one of
// each type of element. We could maybe use a ref here instead?
Expand Down Expand Up @@ -156,7 +168,7 @@ export function StripeForm({
<button
className="btn-primary btn-s"
type="submit"
disabled={processing || !cardValid || succeeded}
/*disabled={processing || !cardValid || succeeded}*/
>
{processing ? <Icon icon="spinner" alt="Progressing" /> : null}
<span>
Expand Down
29 changes: 7 additions & 22 deletions app/javascript/components/donations/TransactionForm.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
import React, { useState } from 'react'
import { ExercismStripeElements } from './donation-form/ExercismStripeElements'
import { StripeForm } from './StripeForm'
import { PaymentIntentType } from './StripeForm'
import React from 'react'
import { AmountButton } from './donation-form/AmountButton'
import { CustomAmountInput } from './donation-form/CustomAmountInput'

type Props = {
defaultAmountInDollars: number
amountInDollars: number
presetAmountsInDollars: number[]
transactionType: PaymentIntentType
onSuccess: (type: PaymentIntentType, amountInDollars: number) => void
onAmountChange: (value: number) => void
}

export const TransactionForm = ({
defaultAmountInDollars,
amountInDollars,
presetAmountsInDollars,
transactionType,
onSuccess,
onAmountChange,
children,
}: React.PropsWithChildren<Props>): JSX.Element => {
const [amountInDollars, setAmountInDollars] = useState(defaultAmountInDollars)

return (
<React.Fragment>
<div>
Expand All @@ -31,27 +24,19 @@ export const TransactionForm = ({
<AmountButton
key={amount}
value={amount}
onClick={setAmountInDollars}
onClick={onAmountChange}
current={amountInDollars}
/>
))}
</div>

<h3>Or specify a custom amount:</h3>
<CustomAmountInput
onChange={setAmountInDollars}
defaultAmount={defaultAmountInDollars}
onChange={onAmountChange}
selected={!presetAmountsInDollars.includes(amountInDollars)}
/>
</div>
</div>
<ExercismStripeElements>
<StripeForm
paymentIntentType={transactionType}
amountInDollars={amountInDollars}
onSuccess={onSuccess}
/>
</ExercismStripeElements>
</React.Fragment>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,18 @@ import React, { useCallback } from 'react'

export const CustomAmountInput = ({
onChange,
defaultAmount,
selected,
}: {
onChange: (amount: number) => void
defaultAmount: number
selected: boolean
}): JSX.Element => {
const handleCustomAmountChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const amount = parseInt(e.target.value)

if (isNaN(amount)) {
onChange(defaultAmount)

return
}

onChange(amount)
},
[onChange, defaultAmount]
[onChange]
)

const classNames = ['c-faux-input', selected ? 'selected' : ''].filter(
Expand Down
18 changes: 18 additions & 0 deletions test/controllers/api/donations/payment_intents_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ class Donations::PaymentIntentsControllerTest < API::BaseTestCase
)
end

test "returns an error if raised" do
error = "oh dear!!"
::Donations::PaymentIntent::Create.expects(:call).raises(Stripe::InvalidRequestError.new(error, nil))

setup_user
post api_donations_payment_intents_path(
type: 'subscription', amount_in_dollars: 10
), headers: @headers, as: :json

assert_response 200
assert_equal(
{
"error" => error
},
JSON.parse(response.body)
)
end

#############
# Succeeded #
#############
Expand Down

0 comments on commit b636054

Please sign in to comment.