feat(client): add google pay (#43117)

* feat: initial button setup client

* feat: rename walletsButton to .tsx

* chore: typescriptize wallet component

* chore: re-add keys to config, env, etc + check in gatsby-node

* feat: refactor donate form and wallet component

* feat(client): set labels correctly

* chore: add stripe package back to server

* chore: add stripe back to allowed paths

* chore: copy donate.js code from PR #41924

* feat: attempt to make back end work

* feat: make redux work

* feat: clean up

* feat: hokify

* feat: add error handling

* fix: back-end should be working

* fix: type errors

* fix: clean up back-end

* feat:addd styles

* feat: connect the client to the api

* feat: display wallets button everywhere

* test: add stripe key for cypress action

* test: fix for cypress tests

* test: cypress tests again

* test: maybe?

* test: more

* test: more

* test: more

* test

* askdfjasklfj

* fix: tests finally?

* revert: remove space from cypress yaml action

* remove logs

Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2021-08-08 23:22:25 +03:00
committed by GitHub
parent ad54684dce
commit b623c340a9
23 changed files with 509 additions and 49 deletions

View File

@ -7277,6 +7277,22 @@
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
"dev": true
},
"stripe": {
"version": "8.168.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.168.0.tgz",
"integrity": "sha512-MQXTarijIOagtLajGe1zBFc9KMbB7jIoFv/kr1WsDPJO/S+/hhZjsXCgBkNvnlwK7Yl0VUn+YrgXl9/9wU6WCw==",
"requires": {
"@types/node": ">=8.1.0",
"qs": "^6.6.0"
},
"dependencies": {
"@types/node": {
"version": "16.4.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.4.12.tgz",
"integrity": "sha512-zxrTNFl9Z8boMJXs6ieqZP0wAhvkdzmHSxTlJabM16cf5G9xBc1uPRH5Bbv2omEDDiM8MzTfqTJXBf0Ba4xFWA=="
}
}
},
"strong-error-handler": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/strong-error-handler/-/strong-error-handler-3.5.0.tgz",

View File

@ -68,6 +68,7 @@
"passport-mock-strategy": "^2.0.0",
"query-string": "^6.14.0",
"rx": "^4.1.0",
"stripe": "^8.168.0",
"uuid": "^3.4.0",
"validator": "^9.4.1"
},

View File

@ -1,4 +1,6 @@
import debug from 'debug';
import Stripe from 'stripe';
import { donationSubscriptionConfig } from '../../../../config/donation-settings';
import keys from '../../../../config/secrets';
import {
getAsyncPaypalToken,
@ -6,17 +8,139 @@ import {
updateUser,
verifyWebHookType
} from '../utils/donation';
import { validStripeForm } from '../utils/stripeHelpers';
const log = debug('fcc:boot:donate');
export default function donateBoot(app, done) {
let stripe = false;
const { User } = app.models;
const api = app.loopback.Router();
const hooks = app.loopback.Router();
const donateRouter = app.loopback.Router();
function addDonation(req, res) {
function connectToStripe() {
return new Promise(function () {
// connect to stripe API
stripe = Stripe(keys.stripe.secret);
});
}
function createStripeDonation(req, res) {
const { user, body } = req;
const {
amount,
duration,
token: { id },
email,
name
} = body;
if (!validStripeForm(amount, duration, email)) {
return res.status(500).send({
error: 'The donation form had invalid values for this submission.'
});
}
const fccUser = user
? Promise.resolve(user)
: new Promise((resolve, reject) =>
User.findOrCreate(
{ where: { email } },
{ email },
(err, instance) => {
if (err) {
return reject(err);
}
return resolve(instance);
}
)
);
let donatingUser = {};
let donation = {
email,
amount,
duration,
provider: 'stripe',
startDate: new Date(Date.now()).toISOString()
};
const createCustomer = async user => {
let customer;
donatingUser = user;
try {
customer = await stripe.customers.create({
email,
card: id,
name
});
} catch (err) {
throw new Error('Error creating stripe customer');
}
log(`Stripe customer with id ${customer.id} created`);
return customer;
};
const createSubscription = async customer => {
donation.customerId = customer.id;
let sub;
try {
sub = await stripe.subscriptions.create({
customer: customer.id,
items: [
{
plan: `${donationSubscriptionConfig.duration[
duration
].toLowerCase()}-donation-${amount}`
}
]
});
} catch (err) {
throw new Error('Error creating stripe subscription');
}
return sub;
};
const createAsyncUserDonation = () => {
donatingUser
.createDonation(donation)
.toPromise()
.catch(err => {
throw new Error(err);
});
};
return Promise.resolve(fccUser)
.then(nonDonatingUser => {
// the logic is removed since users can donate without an account
return nonDonatingUser;
})
.then(createCustomer)
.then(customer => {
return createSubscription(customer).then(subscription => {
log(`Stripe subscription with id ${subscription.id} created`);
donation.subscriptionId = subscription.id;
return res.status(200);
});
})
.then(createAsyncUserDonation)
.catch(err => {
if (
err.type === 'StripeCardError' ||
err.type === 'AlreadyDonatingError'
) {
return res.status(402).send({ error: err.message });
}
return res
.status(500)
.send({ error: 'Donation failed due to a server error.' });
});
}
function addDonation(req, res) {
const { user, body } = req;
if (!user || !body) {
return res
.status(500)
@ -52,28 +176,37 @@ export default function donateBoot(app, done) {
.finally(() => res.status(200).json({ message: 'received paypal hook' }));
}
const stripeKey = keys.stripe.public;
const secKey = keys.stripe.secret;
const paypalKey = keys.paypal.client;
const paypalSec = keys.paypal.secret;
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 =
!paypalSec || paypalSec === 'secret_from_paypal_dashboard';
const stripeInvalid = stripeSecretInvalid || stripPublicInvalid;
const paypalInvalid = paypalPublicInvalid || paypalSecretInvalid;
if (paypalInvalid) {
if (stripeInvalid || paypalInvalid) {
if (process.env.FREECODECAMP_NODE_ENV === 'production') {
throw new Error('Donation API keys are required to boot the server!');
}
log('Donation disabled in development unless ALL test keys are provided');
done();
} else {
api.post('/charge-stripe', createStripeDonation);
api.post('/add-donation', addDonation);
hooks.post('/update-paypal', updatePaypal);
donateRouter.use('/donate', api);
donateRouter.use('/hooks', hooks);
app.use(donateRouter);
connectToStripe(stripe).then(done);
done();
}
}

View File

@ -14,7 +14,9 @@ export default function getCsurf() {
const { path } = req;
if (
// eslint-disable-next-line max-len
/^\/hooks\/update-paypal$/.test(path)
/^\/hooks\/update-paypal$|^\/hooks\/update-stripe$|^\/donate\/charge-stripe$/.test(
path
)
) {
next();
} else {

View File

@ -23,7 +23,10 @@ const signinRE = /^\/signin/;
const statusRE = /^\/status\/ping$/;
const unsubscribedRE = /^\/unsubscribed\//;
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
const updateHooksRE = /^\/hooks\/update-paypal$/;
const updateHooksRE = /^\/hooks\/update-paypal$|^\/hooks\/update-stripe$/;
const createStripeSession = /^\/donate\/create-stripe-session/;
// note: this would be replaced by webhooks later
const donateRE = /^\/donate\/charge-stripe$/;
const _pathsAllowedREs = [
authRE,
@ -37,7 +40,9 @@ const _pathsAllowedREs = [
statusRE,
unsubscribedRE,
unsubscribeRE,
updateHooksRE
updateHooksRE,
donateRE,
createStripeSession
];
export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) {

View File

@ -45,7 +45,7 @@ describe('request-authorization', () => {
const statusRE = /^\/status\/ping$/;
const unsubscribedRE = /^\/unsubscribed\//;
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
const updateHooksRE = /^\/hooks\/update-paypal$/;
const updateHooksRE = /^\/hooks\/update-paypal$|^\/hooks\/update-stripe$/;
const allowedPathsList = [
authRE,
@ -77,9 +77,11 @@ describe('request-authorization', () => {
allowedPathsList
);
const resultC = isAllowedPath('/hooks/update-paypal', allowedPathsList);
const resultD = isAllowedPath('/hooks/update-stripe', allowedPathsList);
expect(resultA).toBe(true);
expect(resultB).toBe(true);
expect(resultC).toBe(true);
expect(resultD).toBe(true);
});
it('returns false for a non-white-listed path', () => {

View File

@ -0,0 +1,15 @@
import { isEmail, isNumeric } from 'validator';
import {
durationKeysConfig,
donationOneTimeConfig,
donationSubscriptionConfig
} from '../../../../config/donation-settings';
export function validStripeForm(amount, duration, email) {
return isEmail('' + email) &&
isNumeric('' + amount) &&
durationKeysConfig.includes(duration) &&
duration === 'onetime'
? donationOneTimeConfig.includes(amount)
: donationSubscriptionConfig.plans[duration];
}

View File

@ -11,6 +11,7 @@ ARG CLIENT_LOCALE
ARG CURRICULUM_LOCALE
ARG ALGOLIA_APP_ID
ARG ALGOLIA_API_KEY
ARG STRIPE_PUBLIC_KEY
ARG PAYPAL_CLIENT_ID
ARG DEPLOYMENT_ENV
ARG SHOW_UPCOMING_CHANGES

View File

@ -53,6 +53,16 @@ exports.createPages = function createPages({ graphql, actions, reporter }) {
}
}
if (!env.stripePublicKey) {
if (process.env.FREECODECAMP_NODE_ENV === 'production') {
throw new Error('Stripe public key is required to start the client!');
} else {
reporter.info(
'Stripe public key is missing or invalid. Required for Stripe integration.'
);
}
}
const { createPage } = actions;
return new Promise((resolve, reject) => {

View File

@ -307,6 +307,8 @@
"confirm-2": "Confirm your one-time donation of ${{usd}}",
"confirm-3": "Confirm your donation of ${{usd}} / month",
"confirm-4": "Confirm your donation of ${{usd}} / year",
"wallet-label": "${{usd}} donation to freeCodeCamp",
"wallet-label-1": "${{usd}} / month donation to freeCodeCamp",
"your-donation": "Your ${{usd}} donation will provide {{hours}} hours of learning to people around the world.",
"your-donation-2": "Your ${{usd}} donation will provide {{hours}} hours of learning to people around the world each month.",
"your-donation-3": "Your ${{usd}} donation will provide {{hours}} hours of learning to people around the world each year.",
@ -444,6 +446,7 @@
"cert-claim-success": "@{{username}}, you have successfully claimed the {{name}} Certification! Congratulations on behalf of the freeCodeCamp.org team!",
"wrong-name": "Something went wrong with the verification of {{name}}, please try again. If you continue to receive this error, you can send a message to support@freeCodeCamp.org to get help.",
"error-claiming": "Error claiming {{certName}}",
"refresh-needed": "You can only use the PaymentRequest button once. Refresh the page to start over.",
"username-not-found": "We could not find a user with the username \"{{username}}\"",
"add-name": "This user needs to add their name to their account in order for others to be able to view their certification.",
"not-eligible": "This user is not eligible for freeCodeCamp.org certifications at this time.",

View File

@ -3977,6 +3977,19 @@
}
}
},
"@stripe/react-stripe-js": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-1.4.1.tgz",
"integrity": "sha512-FjcVrhf72+9fUL3Lz3xi02ni9tzH1A1x6elXlr6tvBDgSD55oPJuodoP8eC7xTnBIKq0olF5uJvgtkJyDCdzjA==",
"requires": {
"prop-types": "^15.7.2"
}
},
"@stripe/stripe-js": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.16.0.tgz",
"integrity": "sha512-ZSHbiwTrISoaTbpercmYGuY7QTg7HxfFyNgbJBaYbwHWbzMhpEdGTsmMpaBXIU6iiqwEEDaIyD8O6yJ+H5DWCg=="
},
"@szmarczak/http-timer": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",

View File

@ -53,7 +53,9 @@
"@freecodecamp/strip-comments": "3.0.1",
"@loadable/component": "5.15.0",
"@reach/router": "1.3.4",
"@types/react-scrollable-anchor": "0.6.1",
"@stripe/react-stripe-js": "^1.4.1",
"@stripe/stripe-js": "^1.16.0",
"@types/react-scrollable-anchor": "^0.6.1",
"algoliasearch": "4.10.3",
"assert": "2.0.0",
"babel-plugin-preval": "5.0.0",

View File

@ -1,7 +1,6 @@
/* 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 {
Col,
@ -11,6 +10,8 @@ import {
ToggleButton,
ToggleButtonGroup
} from '@freecodecamp/react-bootstrap';
import type { Token } from '@stripe/stripe-js';
import React, { Component } from 'react';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
@ -30,7 +31,8 @@ import {
addDonation,
updateDonationFormState,
defaultDonationFormState,
userSelector
userSelector,
postChargeStripe
} from '../../redux';
import Spacer from '../helpers/spacer';
@ -38,6 +40,7 @@ import DonateCompletion from './DonateCompletion';
import type { AddDonationData } from './PaypalButton';
import PaypalButton from './PaypalButton';
import WalletsWrapper from './walletsButton';
import './Donation.css';
@ -55,6 +58,7 @@ type DonateFormState = {
type DonateFormProps = {
addDonation: (data: unknown) => unknown;
postChargeStripe: (data: unknown) => unknown;
defaultTheme?: string;
email: string;
handleProcessing: (duration: string, amount: number, action: string) => void;
@ -91,7 +95,8 @@ const mapStateToProps = createSelector(
const mapDispatchToProps = {
addDonation,
updateDonationFormState
updateDonationFormState,
postChargeStripe
};
class DonateForm extends Component<DonateFormProps, DonateFormState> {
@ -126,6 +131,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
this.handleSelectDuration = this.handleSelectDuration.bind(this);
this.hideAmountOptionsCB = this.hideAmountOptionsCB.bind(this);
this.resetDonation = this.resetDonation.bind(this);
this.postStripeDonation = this.postStripeDonation.bind(this);
}
componentWillUnmount() {
@ -180,6 +186,28 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
this.setState({ donationDuration, donationAmount });
}
postStripeDonation(
token: Token,
payerEmail: string | undefined,
payerName: string | undefined
) {
const { donationAmount: amount, donationDuration: duration } = this.state;
window.scrollTo(0, 0);
// change the donation modal button label to close
// or display the close button for the cert donation section
if (this.props.handleProcessing) {
this.props.handleProcessing(duration, amount, 'Stripe payment submition');
}
this.props.postChargeStripe({
token,
amount,
duration,
email: payerEmail,
name: payerName
});
}
handleSelectAmount(donationAmount: number) {
this.setState({ donationAmount });
}
@ -277,20 +305,31 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
theme
} = this.props;
const { donationAmount, donationDuration } = this.state;
const isOneTime = donationDuration === 'onetime';
const formlabel = `${t(
isOneTime ? 'donate.confirm-2' : 'donate.confirm-3',
{ usd: donationAmount / 100 }
)}:`;
const walletlabel = `${t(
isOneTime ? 'donate.wallet-label' : 'donate.wallet-label-1',
{ usd: donationAmount / 100 }
)}:`;
const priorityTheme = defaultTheme ? defaultTheme : theme;
return (
<div>
{isOneTime ? (
<b>
{t('donate.confirm-1')} {donationAmount / 100}:
</b>
) : (
<b>{t('donate.confirm-3', { usd: donationAmount / 100 })}:</b>
)}
<b>{formlabel}</b>
<Spacer />
<div className='donate-btn-group'>
<WalletsWrapper
amount={donationAmount}
label={walletlabel}
onDonationStateChange={this.onDonationStateChange}
postStripeDonation={this.postStripeDonation}
refreshErrorMessage={t('donate.refresh-needed')}
theme={priorityTheme}
/>
<PaypalButton
addDonation={addDonation}
donationAmount={donationAmount}
@ -299,7 +338,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
isSubscription={isOneTime ? false : true}
onDonationStateChange={this.onDonationStateChange}
skipAddDonation={!isSignedIn}
theme={defaultTheme ? defaultTheme : theme}
theme={priorityTheme}
/>
</div>
</div>
@ -322,21 +361,37 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
renderModalForm() {
const { donationAmount, donationDuration } = this.state;
const { handleProcessing, addDonation, defaultTheme, theme } = this.props;
const { handleProcessing, addDonation, defaultTheme, theme, t } =
this.props;
const priorityTheme = defaultTheme ? defaultTheme : theme;
const isOneTime = donationDuration === 'onetime';
const walletlabel = `${t(
isOneTime ? 'donate.wallet-label' : 'donate.wallet-label-1',
{ usd: donationAmount / 100 }
)}:`;
return (
<Row>
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
<b className='donation-label'>{this.getDonationButtonLabel()}:</b>
<Spacer />
<b>{this.getDonationButtonLabel()}:</b>
<Spacer />
<PaypalButton
addDonation={addDonation}
donationAmount={donationAmount}
donationDuration={donationDuration}
handleProcessing={handleProcessing}
onDonationStateChange={this.onDonationStateChange}
theme={defaultTheme ? defaultTheme : theme}
/>
<div className='donate-btn-group'>
<WalletsWrapper
amount={donationAmount}
label={walletlabel}
onDonationStateChange={this.onDonationStateChange}
postStripeDonation={this.postStripeDonation}
refreshErrorMessage={t('donate.refresh-needed')}
theme={priorityTheme}
/>
<PaypalButton
addDonation={addDonation}
donationAmount={donationAmount}
donationDuration={donationDuration}
handleProcessing={handleProcessing}
onDonationStateChange={this.onDonationStateChange}
theme={defaultTheme ? defaultTheme : theme}
/>
</div>
</Col>
</Row>
);

View File

@ -245,11 +245,13 @@ li.disabled > a {
font-weight: 600;
font-size: 1.2rem;
}
.donation-label,
.donation-modal p,
.donation-modal b {
text-align: center;
font-size: 1rem;
width: 100%;
display: inline-block;
}
.donation-icon-container {
@ -386,31 +388,23 @@ button#confirm-donation-btn:hover {
.donate-btn-group {
display: flex;
flex-direction: row;
flex-direction: column;
justify-content: center;
align-items: center;
}
.donate-btn-group > * {
width: 100%;
height: 43px;
}
.donate-btn-group button:first-child {
margin-bottom: 10px;
.wallets-form {
margin-bottom: 12px;
}
@media (min-width: 500px) {
.donate-btn-group {
flex-direction: row;
}
.donate-btn-group > * {
width: 49%;
}
.donate-btn-group button:first-child {
margin-bottom: 0px;
margin-right: auto;
}
}
.donate-page-wrapper .alert.alert-info a:hover {

View File

@ -0,0 +1,131 @@
import {
PaymentRequestButtonElement,
Elements,
ElementsConsumer
} from '@stripe/react-stripe-js';
import { Stripe, loadStripe } from '@stripe/stripe-js';
import type { Token, PaymentRequest } from '@stripe/stripe-js';
import React, { useState, useEffect } from 'react';
import envData from '../../../../config/env.json';
import { AddDonationData } from './PaypalButton';
const { stripePublicKey }: { stripePublicKey: string | null } = envData;
interface WrapperProps {
label: string;
amount: number;
theme: string;
postStripeDonation: (
token: Token,
payerEmail: string | undefined,
payerName: string | undefined
) => void;
onDonationStateChange: (donationState: AddDonationData) => void;
refreshErrorMessage: string;
}
interface WalletsButtonProps extends WrapperProps {
stripe: Stripe | null;
}
const WalletsButton = ({
stripe,
label,
amount,
theme,
refreshErrorMessage,
postStripeDonation,
onDonationStateChange
}: WalletsButtonProps) => {
const [token, setToken] = useState<Token | null>(null);
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest | null>(
null
);
const [canMakePayment, checkpaymentPossiblity] = useState(false);
useEffect(() => {
if (!stripe) {
return;
}
const pr = stripe.paymentRequest({
country: 'US',
currency: 'usd',
total: { label, amount },
requestPayerName: true,
requestPayerEmail: true,
disableWallets: ['browserCard']
});
pr.on('token', event => {
const { token, payerEmail, payerName } = event;
setToken(token);
event.complete('success');
postStripeDonation(token, payerEmail, payerName);
});
void pr.canMakePayment().then(canMakePaymentRes => {
if (canMakePaymentRes) {
setPaymentRequest(pr);
checkpaymentPossiblity(true);
} else {
checkpaymentPossiblity(false);
}
});
}, [label, amount, stripe, postStripeDonation]);
const displayRefreshError = (): void => {
onDonationStateChange({
redirecting: false,
processing: false,
success: false,
error: refreshErrorMessage
});
};
return (
<form className='wallets-form'>
{canMakePayment && paymentRequest && (
<PaymentRequestButtonElement
onClick={() => {
if (token) {
displayRefreshError();
}
}}
options={{
style: {
paymentRequestButton: {
type: 'default',
theme: theme === 'night' ? 'light' : 'dark',
height: '43px'
}
},
paymentRequest
}}
/>
)}
</form>
);
};
const InjectedCheckoutForm = (props: WrapperProps): JSX.Element => (
<ElementsConsumer>
{({ stripe }: { stripe: Stripe | null }) => (
<WalletsButton stripe={stripe} {...props} />
)}
</ElementsConsumer>
);
const WalletsWrapper = (props: WrapperProps): JSX.Element | null => {
if (!stripePublicKey) {
return null;
} else {
const stripePromise = loadStripe(stripePublicKey);
return (
<Elements stripe={stripePromise}>
<InjectedCheckoutForm {...props} />
</Elements>
);
}
};
export default WalletsWrapper;

View File

@ -27,7 +27,8 @@ export const actionTypes = createTypes(
...createAsyncTypes('fetchProfileForUser'),
...createAsyncTypes('acceptTerms'),
...createAsyncTypes('showCert'),
...createAsyncTypes('reportUser')
...createAsyncTypes('reportUser'),
...createAsyncTypes('postChargeStripe')
],
ns
);

View File

@ -1,5 +1,13 @@
import { put, select, takeEvery, delay, call, take } from 'redux-saga/effects';
import { addDonation } from '../utils/ajax';
import {
put,
select,
takeEvery,
takeLeading,
delay,
call,
take
} from 'redux-saga/effects';
import { addDonation, postChargeStripe } from '../utils/ajax';
import { actionTypes as appTypes } from './action-types';
import {
@ -9,7 +17,9 @@ import {
preventProgressDonationRequests,
recentlyClaimedBlockSelector,
addDonationComplete,
addDonationError
addDonationError,
postChargeStripeComplete,
postChargeStripeError
} from './';
const defaultDonationError = `Something is not right. Please contact donors@freecodecamp.org`;
@ -44,9 +54,23 @@ function* addDonationSaga({ payload }) {
}
}
function* postChargeStripeSaga({ payload }) {
try {
yield call(postChargeStripe, payload);
yield put(postChargeStripeComplete());
} catch (error) {
const err =
error.response && error.response.data
? error.response.data.error
: defaultDonationError;
yield put(postChargeStripeError(err));
}
}
export function createDonationSaga(types) {
return [
takeEvery(types.tryToShowDonationModal, showDonateModalSaga),
takeEvery(types.addDonation, addDonationSaga)
takeEvery(types.addDonation, addDonationSaga),
takeLeading(types.postChargeStripe, postChargeStripeSaga)
];
}

View File

@ -125,6 +125,14 @@ export const addDonationComplete = createAction(
);
export const addDonationError = createAction(actionTypes.addDonationError);
export const postChargeStripe = createAction(actionTypes.postChargeStripe);
export const postChargeStripeComplete = createAction(
actionTypes.postChargeStripeComplete
);
export const postChargeStripeError = createAction(
actionTypes.postChargeStripeError
);
export const fetchProfileForUser = createAction(
actionTypes.fetchProfileForUser
);
@ -415,6 +423,29 @@ export const reducer = handleActions(
...state,
donationFormState: { ...defaultDonationFormState, error: payload }
}),
[actionTypes.postChargeStripe]: state => ({
...state,
donationFormState: { ...defaultDonationFormState, processing: true }
}),
[actionTypes.postChargeStripeComplete]: state => {
const { appUsername } = state;
return {
...state,
user: {
...state.user,
[appUsername]: {
...state.user[appUsername],
isDonating: true
}
},
donationFormState: { ...defaultDonationFormState, success: true }
};
},
[actionTypes.postChargeStripeError]: (state, { payload }) => ({
...state,
donationFormState: { ...defaultDonationFormState, error: payload }
}),
[actionTypes.fetchUser]: state => ({
...state,
userFetchState: { ...defaultFetchState }

View File

@ -104,6 +104,9 @@ export function addDonation(body: Donation): Promise<void> {
return post('/donate/add-donation', body);
}
export function postChargeStripe(body: Donation): Promise<void> {
return post('/donate/charge-stripe', body);
}
interface Report {
username: string;
reportDescription: string;

View File

@ -23,6 +23,7 @@ const {
SHOW_LOCALE_DROPDOWN_MENU: showLocaleDropdownMenu,
ALGOLIA_APP_ID: algoliaAppId,
ALGOLIA_API_KEY: algoliaAPIKey,
STRIPE_PUBLIC_KEY: stripePublicKey,
PAYPAL_CLIENT_ID: paypalClientId,
DEPLOYMENT_ENV: deploymentEnv,
SHOW_UPCOMING_CHANGES: showUpcomingChanges
@ -52,6 +53,10 @@ module.exports = Object.assign(locations, {
!algoliaAPIKey || algoliaAPIKey === 'api_key_from_algolia_dashboard'
? ''
: algoliaAPIKey,
stripePublicKey:
!stripePublicKey || stripePublicKey === 'pk_from_stripe_dashboard'
? null
: stripePublicKey,
paypalClientId:
!paypalClientId || paypalClientId === 'id_from_paypal_dashboard'
? null

View File

@ -29,6 +29,9 @@ const {
SENTRY_DSN,
STRIPE_PUBLIC_KEY,
STRIPE_SECRET_KEY,
PAYPAL_CLIENT_ID,
PAYPAL_SECRET,
PAYPAL_VERIFY_WEBHOOK_URL,
@ -92,6 +95,11 @@ module.exports = {
dns: SENTRY_DSN
},
stripe: {
public: STRIPE_PUBLIC_KEY,
secret: STRIPE_SECRET_KEY
},
paypal: {
client: PAYPAL_CLIENT_ID,
secret: PAYPAL_SECRET,

View File

@ -31,6 +31,11 @@ ALGOLIA_API_KEY=api_key_from_algolia_dashboard
# Donations
# ---------------------
# Stripe
STRIPE_CREATE_PLANS=true
STRIPE_PUBLIC_KEY=pk_from_stripe_dashboard
STRIPE_SECRET_KEY=sk_from_stripe_dashboard
# PayPal
PAYPAL_CLIENT_ID=id_from_paypal_dashboard
PAYPAL_SECRET=secret_from_paypal_dashboard

View File

@ -45,7 +45,7 @@ if (FREECODECAMP_NODE_ENV !== 'development') {
'showUpcomingChanges'
];
const searchKeys = ['algoliaAppId', 'algoliaAPIKey'];
const donationKeys = ['paypalClientId'];
const donationKeys = ['stripePublicKey', 'paypalClientId'];
const expectedVariables = locationKeys.concat(
deploymentKeys,