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 Stripe card form #43433

Merged
merged 11 commits into from Sep 17, 2021
18 changes: 16 additions & 2 deletions api-server/src/server/boot/donate.js
@@ -1,12 +1,14 @@
import debug from 'debug';
import Stripe from 'stripe';

import { donationSubscriptionConfig } from '../../../../config/donation-settings';
import keys from '../../../../config/secrets';
import {
getAsyncPaypalToken,
verifyWebHook,
updateUser,
verifyWebHookType
verifyWebHookType,
createStripeCardDonation
} from '../utils/donation';
import { validStripeForm } from '../utils/stripeHelpers';

Expand All @@ -26,6 +28,18 @@ export default function donateBoot(app, done) {
});
}

async function handleStripeCardDonation(req, res) {
return createStripeCardDonation(req, res, stripe, app).catch(err => {
if (err.type === 'AlreadyDonatingError')
return res.status(402).send({ error: err });
if (err.type === 'InvalidRequest')
return res.status(400).send({ error: err });
return res
.status(500)
.send({ message: 'Donation failed due to a server error.' });
});
}

function createStripeDonation(req, res) {
const { user, body } = req;

Expand Down Expand Up @@ -184,7 +198,6 @@ export default function donateBoot(app, done) {
const stripeSecretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard';
const stripPublicInvalid =
!stripeKey || stripeKey === 'pk_from_stripe_dashboard';

const paypalSecretInvalid =
!paypalKey || paypalKey === 'id_from_paypal_dashboard';
const paypalPublicInvalid =
Expand All @@ -201,6 +214,7 @@ export default function donateBoot(app, done) {
done();
} else {
api.post('/charge-stripe', createStripeDonation);
api.post('/charge-stripe-card', handleStripeCardDonation);
api.post('/add-donation', addDonation);
hooks.post('/update-paypal', updatePaypal);
donateRouter.use('/donate', api);
Expand Down
59 changes: 59 additions & 0 deletions api-server/src/server/utils/donation.js
@@ -1,6 +1,7 @@
/* eslint-disable camelcase */
import axios from 'axios';
import debug from 'debug';
import { donationSubscriptionConfig } from '../../../../config/donation-settings';
import keys from '../../../../config/secrets';

const log = debug('fcc:boot:donate');
Expand Down Expand Up @@ -171,3 +172,61 @@ export async function updateUser(body, app) {
type: 'UnsupportedWebhookType'
};
}

export async function createStripeCardDonation(req, res, stripe) {
const {
body: {
token: { id: tokenId },
amount,
duration
},
user: { name, id: userId, email },
user
} = req;

if (!tokenId || !amount || !duration || !name || !userId || !email) {
throw {
message: 'Request is not valid',
type: 'InvalidRequest'
ahmaxed marked this conversation as resolved.
Show resolved Hide resolved
};
}

if (user.isDonating && duration !== 'onetime') {
throw {
message: `User already has active recurring donation(s).`,
type: 'AlreadyDonatingError'
};
}

const { id: customerId } = await stripe.customers.create({
email,
card: tokenId,
name
});
log(`Stripe customer with id ${customerId} created`);

const { id: subscriptionId } = await stripe.subscriptions.create({
customer: customerId,
items: [
{
plan: `${donationSubscriptionConfig.duration[
duration
].toLowerCase()}-donation-${amount}`
}
]
});
log(`Stripe subscription with id ${subscriptionId} created`);

// save Donation
let donation = {
email,
amount,
duration,
provider: 'stripe',
subscriptionId,
customerId,
startDate: new Date().toISOString()
};
await createAsyncUserDonation(user, donation);
return res.status(200).json({ isDonating: true });
}
1 change: 1 addition & 0 deletions client/i18n/locales/english/translations.json
Expand Up @@ -319,6 +319,7 @@
"nicely-done": "Nicely done. You just completed {{block}}.",
"credit-card": "Credit Card",
"credit-card-2": "Or donate with a credit card:",
"or-card": "Or donate with card",
"paypal": "with PayPal:",
"need-email": "We need a valid email address to which we can send your donation tax receipt.",
"went-wrong": "Something went wrong processing your donation. Your card has not been charged.",
Expand Down
74 changes: 52 additions & 22 deletions client/src/components/Donation/DonateForm.tsx
@@ -1,8 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable no-nested-ternary */

import type { Token } from '@stripe/stripe-js';
import React, { Component } from 'react';
import { withTranslation } from 'react-i18next';
Expand All @@ -25,14 +22,14 @@ import {
updateDonationFormState,
defaultDonationFormState,
userSelector,
postChargeStripe
postChargeStripe,
postChargeStripeCard
} from '../../redux';
import Spacer from '../helpers/spacer';

import DonateCompletion from './DonateCompletion';

import type { AddDonationData } from './PaypalButton';
import PaypalButton from './PaypalButton';
import StripeCardForm from './stripe-card-form';
import WalletsWrapper from './walletsButton';

import './Donation.css';
Expand All @@ -51,14 +48,19 @@ type DonateFormState = {
};
};

type DonateFromComponentState = {
type DonateFormComponentState = {
donationAmount: number;
donationDuration: string;
};

type DonateFormProps = {
addDonation: (data: unknown) => unknown;
postChargeStripe: (data: unknown) => unknown;
postChargeStripeCard: (data: {
token: Token;
amount: number;
duration: string;
}) => void;
defaultTheme?: string;
email: string;
handleProcessing: (duration: string, amount: number, action: string) => void;
Expand Down Expand Up @@ -96,10 +98,11 @@ const mapStateToProps = createSelector(
const mapDispatchToProps = {
addDonation,
updateDonationFormState,
postChargeStripe
postChargeStripe,
postChargeStripeCard
};

class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
static displayName = 'DonateForm';
durations: { month: 'monthly'; onetime: 'one-time' };
amounts: { month: number[]; onetime: number[] };
Expand All @@ -125,6 +128,7 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
this.handleSelectDuration = this.handleSelectDuration.bind(this);
this.resetDonation = this.resetDonation.bind(this);
this.postStripeDonation = this.postStripeDonation.bind(this);
this.postStripeCardDonation = this.postStripeCardDonation.bind(this);
this.handlePaymentButtonLoad = this.handlePaymentButtonLoad.bind(this);
}

Expand Down Expand Up @@ -217,6 +221,20 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
});
}

postStripeCardDonation(token: Token) {
const { donationAmount: amount, donationDuration: duration } = this.state;
this.props.handleProcessing(
duration,
amount,
'Stripe card payment submission'
);
this.props.postChargeStripeCard({
token,
amount,
duration
});
}

handleSelectAmount(donationAmount: number) {
this.setState({ donationAmount });
}
Expand All @@ -227,15 +245,15 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
const usd = this.getFormattedAmountLabel(donationAmount);
const hours = this.convertToTimeContributed(donationAmount);

return (
<p className='donation-description'>
{donationDuration === 'onetime'
? t('donate.your-donation', { usd: usd, hours: hours })
: donationDuration === 'month'
? t('donate.your-donation-2', { usd: usd, hours: hours })
: t('donate.your-donation-3', { usd: usd, hours: hours })}
</p>
);
let donationDescription = t('donate.your-donation-3', { usd, hours });

if (donationDuration === 'onetime') {
donationDescription = t('donate.your-donation', { usd, hours });
} else if (donationDuration === 'month') {
donationDescription = t('donate.your-donation-2', { usd, hours });
}

return <p className='donation-description'>{donationDescription}</p>;
}

resetDonation() {
Expand Down Expand Up @@ -267,7 +285,7 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
renderButtonGroup() {
const { donationAmount, donationDuration } = this.state;
const {
donationFormState: { loading },
donationFormState: { loading, processing },
handleProcessing,
addDonation,
defaultTheme,
Expand All @@ -276,7 +294,6 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
isMinimalForm,
isSignedIn
} = this.props;
const paymentButtonsLoading = loading.stripe && loading.paypal;
const priorityTheme = defaultTheme ? defaultTheme : theme;
const isOneTime = donationDuration === 'onetime';
const walletlabel = `${t(
Expand All @@ -290,8 +307,8 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
{this.getDonationButtonLabel()}:
</b>
<Spacer />
{paymentButtonsLoading && this.paymentButtonsLoader()}
<div className={'donate-btn-group'}>
{loading.stripe && loading.paypal && this.paymentButtonsLoader()}
<WalletsWrapper
amount={donationAmount}
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
Expand All @@ -307,11 +324,24 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
donationDuration={donationDuration}
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
handleProcessing={handleProcessing}
isMinimalForm={isMinimalForm}
isPaypalLoading={loading.paypal}
isSignedIn={isSignedIn}
onDonationStateChange={this.onDonationStateChange}
theme={defaultTheme ? defaultTheme : theme}
/>
{isMinimalForm && (
<>
<div className='separator'>{t('donate.or-card')}</div>
<StripeCardForm
onDonationStateChange={this.onDonationStateChange}
postStripeCardDonation={this.postStripeCardDonation}
processing={processing}
t={t}
theme={defaultTheme ? defaultTheme : theme}
/>
</>
)}
</div>
</>
);
Expand Down