diff --git a/api-server/package-lock.json b/api-server/package-lock.json index 7f826fe369..de481b3e81 100644 --- a/api-server/package-lock.json +++ b/api-server/package-lock.json @@ -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", diff --git a/api-server/package.json b/api-server/package.json index a1266a0a1e..cd6ea57856 100644 --- a/api-server/package.json +++ b/api-server/package.json @@ -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" }, diff --git a/api-server/src/server/boot/donate.js b/api-server/src/server/boot/donate.js index 4bdd7802b9..69bc06f948 100644 --- a/api-server/src/server/boot/donate.js +++ b/api-server/src/server/boot/donate.js @@ -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(); } } diff --git a/api-server/src/server/middlewares/csurf.js b/api-server/src/server/middlewares/csurf.js index 126f725d57..cd886dd386 100644 --- a/api-server/src/server/middlewares/csurf.js +++ b/api-server/src/server/middlewares/csurf.js @@ -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 { diff --git a/api-server/src/server/middlewares/request-authorization.js b/api-server/src/server/middlewares/request-authorization.js index 8d5970e7bb..9b3485112d 100644 --- a/api-server/src/server/middlewares/request-authorization.js +++ b/api-server/src/server/middlewares/request-authorization.js @@ -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) { diff --git a/api-server/src/server/middlewares/request-authorization.test.js b/api-server/src/server/middlewares/request-authorization.test.js index 593d5930ed..14d600e201 100644 --- a/api-server/src/server/middlewares/request-authorization.test.js +++ b/api-server/src/server/middlewares/request-authorization.test.js @@ -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', () => { diff --git a/api-server/src/server/utils/stripeHelpers.js b/api-server/src/server/utils/stripeHelpers.js new file mode 100644 index 0000000000..c64e44ce61 --- /dev/null +++ b/api-server/src/server/utils/stripeHelpers.js @@ -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]; +} diff --git a/client.Dockerfile b/client.Dockerfile index 45eaf359cf..a6994c4041 100644 --- a/client.Dockerfile +++ b/client.Dockerfile @@ -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 diff --git a/client/gatsby-node.js b/client/gatsby-node.js index 4d4fa4d406..d1adc1805e 100644 --- a/client/gatsby-node.js +++ b/client/gatsby-node.js @@ -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) => { diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 6a6d26b107..beaaa25d2b 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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.", diff --git a/client/package-lock.json b/client/package-lock.json index 0f2db69157..2ab707300f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index f4d075a459..a9ad774549 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/Donation/DonateForm.tsx b/client/src/components/Donation/DonateForm.tsx index 0aa879dbef..202d58c252 100644 --- a/client/src/components/Donation/DonateForm.tsx +++ b/client/src/components/Donation/DonateForm.tsx @@ -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 { @@ -126,6 +131,7 @@ class DonateForm extends Component { 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 { 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 { 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 (
- {isOneTime ? ( - - {t('donate.confirm-1')} {donationAmount / 100}: - - ) : ( - {t('donate.confirm-3', { usd: donationAmount / 100 })}: - )} + {formlabel}
+ { isSubscription={isOneTime ? false : true} onDonationStateChange={this.onDonationStateChange} skipAddDonation={!isSignedIn} - theme={defaultTheme ? defaultTheme : theme} + theme={priorityTheme} />
@@ -322,21 +361,37 @@ class DonateForm extends Component { 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 ( + {this.getDonationButtonLabel()}: - {this.getDonationButtonLabel()}: - - +
+ + +
); diff --git a/client/src/components/Donation/Donation.css b/client/src/components/Donation/Donation.css index ded24dc7c7..3c5d1ad0d0 100644 --- a/client/src/components/Donation/Donation.css +++ b/client/src/components/Donation/Donation.css @@ -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 { diff --git a/client/src/components/Donation/walletsButton.tsx b/client/src/components/Donation/walletsButton.tsx new file mode 100644 index 0000000000..87c70ff825 --- /dev/null +++ b/client/src/components/Donation/walletsButton.tsx @@ -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(null); + const [paymentRequest, setPaymentRequest] = useState( + 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 ( +
+ {canMakePayment && paymentRequest && ( + { + if (token) { + displayRefreshError(); + } + }} + options={{ + style: { + paymentRequestButton: { + type: 'default', + theme: theme === 'night' ? 'light' : 'dark', + height: '43px' + } + }, + paymentRequest + }} + /> + )} + + ); +}; + +const InjectedCheckoutForm = (props: WrapperProps): JSX.Element => ( + + {({ stripe }: { stripe: Stripe | null }) => ( + + )} + +); + +const WalletsWrapper = (props: WrapperProps): JSX.Element | null => { + if (!stripePublicKey) { + return null; + } else { + const stripePromise = loadStripe(stripePublicKey); + return ( + + + + ); + } +}; + +export default WalletsWrapper; diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index 778053c54e..773df7e39f 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -27,7 +27,8 @@ export const actionTypes = createTypes( ...createAsyncTypes('fetchProfileForUser'), ...createAsyncTypes('acceptTerms'), ...createAsyncTypes('showCert'), - ...createAsyncTypes('reportUser') + ...createAsyncTypes('reportUser'), + ...createAsyncTypes('postChargeStripe') ], ns ); diff --git a/client/src/redux/donation-saga.js b/client/src/redux/donation-saga.js index 40e9490a8b..1b66c21f11 100644 --- a/client/src/redux/donation-saga.js +++ b/client/src/redux/donation-saga.js @@ -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) ]; } diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 33eb196d81..adc3cc3d7b 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -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 } diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index a3a2a33444..b12cebdc72 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -104,6 +104,9 @@ export function addDonation(body: Donation): Promise { return post('/donate/add-donation', body); } +export function postChargeStripe(body: Donation): Promise { + return post('/donate/charge-stripe', body); +} interface Report { username: string; reportDescription: string; diff --git a/config/read-env.js b/config/read-env.js index da9f581148..781c613fab 100644 --- a/config/read-env.js +++ b/config/read-env.js @@ -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 diff --git a/config/secrets.js b/config/secrets.js index 6968531eae..d8b38810b8 100644 --- a/config/secrets.js +++ b/config/secrets.js @@ -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, diff --git a/sample.env b/sample.env index 43e3c50a2e..ebb5b1aec2 100644 --- a/sample.env +++ b/sample.env @@ -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 diff --git a/tools/scripts/build/ensure-env.js b/tools/scripts/build/ensure-env.js index 9704aa5ffd..658a0e7091 100644 --- a/tools/scripts/build/ensure-env.js +++ b/tools/scripts/build/ensure-env.js @@ -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,