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
22 changes: 20 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,22 @@ export default function donateBoot(app, done) {
});
}

async function handleStripeCardDonation(req, res) {
return createStripeCardDonation(req, res, stripe, app).catch(err => {
log(err.message);
if (
err.type === 'LargeNumbersOfDonations' ||
err.type === 'HighDonationFrequency' ||
err.type === 'InvalidRequest'
) {
return res.status(500).send({ error: err.message });
}
ShaunSHamilton marked this conversation as resolved.
Show resolved Hide resolved
return res
.status(500)
.send({ error: 'Donation failed due to a server error.' });
});
}

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

Expand Down Expand Up @@ -184,7 +202,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 +218,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
93 changes: 93 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,95 @@ export async function updateUser(body, app) {
type: 'UnsupportedWebhookType'
};
}

export function hasDonatedToday(donations) {
if (donations.length > 1) {
const milliSecondsInDay = 86400000;
const milliSecondsNow = new Date().getTime();
return donations.some(donation => {
return (
milliSecondsNow - new Date(donation.startDate['_date']).getTime() <
milliSecondsInDay
);
});
}
return false;
}

export async function createStripeCardDonation(req, res, stripe, app) {
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
};
}

// check request validity
const { Donation } = app.models;

Donation.find({ where: { userId } }, (err, donations) => {
if (err) throw Error(err);

// check if a user has already made multiple donations
if (donations.length > 10)
ShaunSHamilton marked this conversation as resolved.
Show resolved Hide resolved
throw {
message: 'Donor has more than 10 donations',
type: 'LargeNumbersOfDonations'
};

// check if a user has made a donation within an hour
if (hasDonatedToday(donations)) {
throw {
message: 'Donor has recently donated',
type: 'HighDonationFrequency'
};
}
});

// todo: check if after signing up the previous completed challenges are sent to api
// and check for minimum completed challegnes.

// create a customer
ahmaxed marked this conversation as resolved.
Show resolved Hide resolved
const { id: customerId } = await stripe.customers.create({
email,
card: tokenId,
name
});
log(`Stripe customer with id ${customerId} created`);

// create a subscription
ahmaxed marked this conversation as resolved.
Show resolved Hide resolved
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 });
}
2 changes: 1 addition & 1 deletion client/package.json
Expand Up @@ -54,7 +54,7 @@
"@freecodecamp/strip-comments": "3.0.1",
"@loadable/component": "5.15.0",
"@reach/router": "1.3.4",
"@stripe/react-stripe-js": "1.4.1",
"@stripe/react-stripe-js": "^1.4.1",
ahmaxed marked this conversation as resolved.
Show resolved Hide resolved
"@stripe/stripe-js": "1.17.1",
"@types/react-scrollable-anchor": "0.6.1",
"algoliasearch": "4.10.5",
Expand Down
72 changes: 51 additions & 21 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,15 @@ type DonateFormState = {
};
};

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

type DonateFormProps = {
addDonation: (data: unknown) => unknown;
postChargeStripe: (data: unknown) => unknown;
postChargeStripeCard: (data: unknown) => unknown;
ahmaxed marked this conversation as resolved.
Show resolved Hide resolved
defaultTheme?: string;
email: string;
handleProcessing: (duration: string, amount: number, action: string) => void;
Expand Down Expand Up @@ -96,10 +94,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 +124,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 +217,24 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
});
}

postStripeCardDonation(token: Token) {
const { email } = this.props;
const { donationAmount: amount, donationDuration: duration } = this.state;
if (this.props.handleProcessing) {
ojeytonwilliams marked this conversation as resolved.
Show resolved Hide resolved
this.props.handleProcessing(
duration,
amount,
'Stripe card payment submission'
);
}
this.props.postChargeStripeCard({
token,
amount,
duration,
email
});
}

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 @@ -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'>Or donate with card</div>
ojeytonwilliams marked this conversation as resolved.
Show resolved Hide resolved

<StripeCardForm
onDonationStateChange={this.onDonationStateChange}
postStripeCardDonation={this.postStripeCardDonation}
t={t}
theme={defaultTheme ? defaultTheme : theme}
/>
</>
)}
</div>
</>
);
Expand Down