diff --git a/.eslintignore b/.eslintignore index b69c5e33fcd..f73fb4cf88b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,6 +5,7 @@ api-server/src/public/** api-server/lib/** config/i18n.js config/certification-settings.js +config/donation-settings.js config/superblock-order.js web/** docs/**/*.md diff --git a/.gitignore b/.gitignore index 76e47146f18..d6cd819d4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -164,6 +164,7 @@ config/client/test-evaluator.json config/curriculum.json config/i18n.js config/certification-settings.js +config/donation-settings.js config/superblock-order.js config/superblock-order.test.js @@ -212,6 +213,7 @@ client/static/_redirects client/static/mobile client/static/curriculum-data client/i18n/locales/**/trending.json +client/src/components/Donation/types.js ### UI Components ### tools/ui-components/dist diff --git a/.prettierignore b/.prettierignore index 2d392969792..5270c577f42 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ curriculum/challenges/**/* config/**/*.json config/i18n.js config/certification-settings.js +config/donation-settings.js config/superblock-order.js config/superblock-order.test.js utils/block-nameify.js @@ -19,3 +20,4 @@ utils/index.js web/.next curriculum-server/data/curriculum.json docs/**/*.md +client/src/components/Donation/types.js \ No newline at end of file diff --git a/api-server/src/server/utils/donation.js b/api-server/src/server/utils/donation.js index a47d1889429..d3d9f86b111 100644 --- a/api-server/src/server/utils/donation.js +++ b/api-server/src/server/utils/donation.js @@ -217,7 +217,7 @@ export async function createStripeCardDonation(req, res, stripe) { * if user is already donating and the donation isn't one time only, * throw error */ - if (user.isDonating && duration !== 'onetime') { + if (user.isDonating && duration !== 'one-time') { throw { message: `User already has active recurring donation(s).`, type: 'AlreadyDonatingError' diff --git a/api-server/src/server/utils/stripeHelpers.js b/api-server/src/server/utils/stripeHelpers.js index c64e44ce618..ae0938d4145 100644 --- a/api-server/src/server/utils/stripeHelpers.js +++ b/api-server/src/server/utils/stripeHelpers.js @@ -9,7 +9,7 @@ export function validStripeForm(amount, duration, email) { return isEmail('' + email) && isNumeric('' + amount) && durationKeysConfig.includes(duration) && - duration === 'onetime' + duration === 'one-time' ? donationOneTimeConfig.includes(amount) : donationSubscriptionConfig.plans[duration]; } diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 462550e5c4b..4bafabea297 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -361,7 +361,6 @@ "become-supporter": "Become a Supporter", "duration": "Become a one-time supporter of our charity.", "duration-2": "Become a monthly supporter of our charity.", - "duration-3": "Become an annual supporter of our charity", "duration-4": "Become a supporter of our charity", "nicely-done": "Nicely done. You just completed {{block}}.", "credit-card": "Credit Card", diff --git a/client/src/client-only-routes/show-certification.tsx b/client/src/client-only-routes/show-certification.tsx index 8e3afd179e8..6566966a211 100644 --- a/client/src/client-only-routes/show-certification.tsx +++ b/client/src/client-only-routes/show-certification.tsx @@ -31,6 +31,7 @@ import certificateMissingMessage from '../utils/certificate-missing-message'; import reallyWeirdErrorMessage from '../utils/really-weird-error-message'; import standardErrorMessage from '../utils/standard-error-message'; +import { PaymentContext } from '../../../config/donation-settings'; import ShowProjectLinks from './show-project-links'; const { clientLocale } = envData; @@ -177,20 +178,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => { setIsDonationClosed(true); }; - const handleProcessing = ( - duration: string, - amount: number, - action: string - ) => { - props.executeGA({ - type: 'event', - data: { - category: 'Donation', - action: `certificate ${action}`, - label: duration, - value: amount - } - }); + const handleProcessing = () => { setIsDonationSubmitted(true); }; @@ -270,6 +258,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => { defaultTheme={Themes.Default} handleProcessing={handleProcessing} isMinimalForm={true} + paymentContext={PaymentContext.Certificate} /> diff --git a/client/src/components/Donation/donate-form.tsx b/client/src/components/Donation/donate-form.tsx index 4c5a91307c5..2a0d40ad2b0 100644 --- a/client/src/components/Donation/donate-form.tsx +++ b/client/src/components/Donation/donate-form.tsx @@ -9,17 +9,11 @@ import { createSelector } from 'reselect'; import { amountsConfig, durationsConfig, - defaultAmount, defaultDonation, modalDefaultDonation } from '../../../../config/donation-settings'; import { defaultDonationFormState } from '../../redux'; -import { - addDonation, - updateDonationFormState, - postChargeStripe, - postChargeStripeCard -} from '../../redux/actions'; +import { updateDonationFormState, postCharge } from '../../redux/actions'; import { isSignedInSelector, userSelector, @@ -32,11 +26,19 @@ import Spacer from '../helpers/spacer'; import { Themes } from '../settings/theme'; import DonateCompletion from './donate-completion'; import PatreonButton from './patreon-button'; -import type { AddDonationData } from './paypal-button'; import PaypalButton from './paypal-button'; -import StripeCardForm, { HandleAuthentication } from './stripe-card-form'; +import StripeCardForm from './stripe-card-form'; import WalletsWrapper from './walletsButton'; import SecurityLockIcon from './security-lock-icon'; +import { + PaymentProvider, + PaymentContext, + PostPayment, + HandleAuthentication, + DonationApprovalData, + DonationAmount, + DonationConfig +} from './types'; import './donation.css'; @@ -54,23 +56,26 @@ type DonateFormState = { }; }; -type DonateFormComponentState = { - donationAmount: number; - donationDuration: string; -}; +type DonateFormComponentState = DonationConfig; + +type PostCharge = (data: { + paymentProvider: PaymentProvider; + paymentContext: PaymentContext; + amount: number; + duration: string; + data?: DonationApprovalData; + token?: Token; + email?: string; + name?: string | undefined; + paymentMethodId?: string; + handleAuthentication?: HandleAuthentication; +}) => void; type DonateFormProps = { - addDonation: (data: unknown) => unknown; - postChargeStripe: (data: unknown) => unknown; - postChargeStripeCard: (data: { - paymentMethodId: string; - amount: number; - duration: string; - handleAuthentication: HandleAuthentication; - }) => void; + postCharge: PostCharge; defaultTheme?: Themes; email: string; - handleProcessing: (duration: string, amount: number, action: string) => void; + handleProcessing?: () => void; donationFormState: DonateFormState; isMinimalForm?: boolean; isSignedIn: boolean; @@ -81,8 +86,9 @@ type DonateFormProps = { { usd, hours }?: { usd?: string | number; hours?: string } ) => string; theme: Themes; - updateDonationFormState: (state: AddDonationData) => unknown; + updateDonationFormState: (state: DonationApprovalData) => unknown; isVariantA: boolean; + paymentContext: PaymentContext; }; const mapStateToProps = createSelector( @@ -111,10 +117,8 @@ const mapStateToProps = createSelector( ); const mapDispatchToProps = { - addDonation, - updateDonationFormState, - postChargeStripe, - postChargeStripeCard + postCharge, + updateDonationFormState }; class DonateForm extends Component { @@ -124,27 +128,20 @@ class DonateForm extends Component { constructor(props: DonateFormProps) { super(props); - this.durations = durationsConfig as { - month: 'monthly'; - onetime: 'one-time'; - }; + this.durations = durationsConfig; this.amounts = amountsConfig; - const initialAmountAndDuration = this.props.isMinimalForm + const initialAmountAndDuration: DonationConfig = this.props.isMinimalForm ? modalDefaultDonation : defaultDonation; this.state = { ...initialAmountAndDuration }; this.onDonationStateChange = this.onDonationStateChange.bind(this); - this.getActiveDonationAmount = this.getActiveDonationAmount.bind(this); this.getDonationButtonLabel = this.getDonationButtonLabel.bind(this); this.handleSelectAmount = this.handleSelectAmount.bind(this); - 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.postPatreonRedirect = this.postPatreonRedirect.bind(this); + this.postPayment = this.postPayment.bind(this); this.handlePaymentButtonLoad = this.handlePaymentButtonLoad.bind(this); } @@ -152,7 +149,7 @@ class DonateForm extends Component { this.resetDonation(); } - onDonationStateChange(donationState: AddDonationData) { + onDonationStateChange(donationState: DonationApprovalData) { // scroll to top window.scrollTo(0, 0); this.props.updateDonationFormState({ @@ -171,16 +168,6 @@ class DonateForm extends Component { }); } - // onload - getActiveDonationAmount( - durationSelected: 'month' | 'onetime', - amountSelected: number - ): number { - return this.amounts[durationSelected].includes(amountSelected) - ? amountSelected - : defaultAmount[durationSelected] || this.amounts[durationSelected][0]; - } - convertToTimeContributed(amount: number) { return numToCommas((amount / 100) * 50); } @@ -194,7 +181,7 @@ class DonateForm extends Component { const { t } = this.props; const usd = this.getFormattedAmountLabel(donationAmount); let donationBtnLabel = t('donate.confirm'); - if (donationDuration === 'onetime') { + if (donationDuration === 'one-time') { donationBtnLabel = t('donate.confirm-2', { usd: usd }); @@ -209,62 +196,34 @@ class DonateForm extends Component { return donationBtnLabel; } - handleSelectDuration(donationDuration: 'month' | 'onetime') { - const donationAmount = this.getActiveDonationAmount(donationDuration, 0); - this.setState({ donationDuration, donationAmount }); - } - - postStripeDonation( - token: Token, - payerEmail: string | undefined, - payerName: string | undefined - ) { - const { email } = this.props; + postPayment = ({ + paymentProvider, + data, + payerEmail, + payerName, + token, + paymentMethodId, + handleAuthentication + }: PostPayment): void => { const { donationAmount: amount, donationDuration: duration } = this.state; - payerEmail = email ? email : payerEmail; - 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({ + const { paymentContext, email } = this.props; + + this.props.postCharge({ + paymentProvider, + paymentContext, + amount, + duration, + data, token, - amount, - duration, - email: payerEmail, - name: payerName - }); - } - - postStripeCardDonation( - paymentMethodId: string, - handleAuthentication: HandleAuthentication - ) { - const { donationAmount: amount, donationDuration: duration } = this.state; - this.props.handleProcessing( - duration, - amount, - 'Stripe card payment submission' - ); - this.props.postChargeStripeCard({ + email: email || payerEmail, + name: payerName, paymentMethodId, - amount, - duration, handleAuthentication }); - } + if (this.props.handleProcessing) this.props.handleProcessing(); + }; - postPatreonRedirect() { - const { donationAmount: amount, donationDuration: duration } = this.state; - this.props.handleProcessing( - duration, - amount, - 'Patreon payment redirection' - ); - } - - handleSelectAmount(donationAmount: number) { + handleSelectAmount(donationAmount: DonationAmount) { this.setState({ donationAmount }); } @@ -276,7 +235,7 @@ class DonateForm extends Component { let donationDescription = t('donate.your-donation-3', { usd, hours }); - if (donationDuration === 'onetime') { + if (donationDuration === 'one-time') { donationDescription = t('donate.your-donation', { usd, hours }); } else if (donationDuration === 'month') { donationDescription = t('donate.your-donation-2', { usd, hours }); @@ -316,8 +275,6 @@ class DonateForm extends Component { const { donationAmount, donationDuration } = this.state; const { donationFormState: { loading, processing }, - handleProcessing, - addDonation, defaultTheme, theme, t, @@ -327,7 +284,7 @@ class DonateForm extends Component { isVariantA } = this.props; const priorityTheme = defaultTheme ? defaultTheme : theme; - const isOneTime = donationDuration === 'onetime'; + const isOneTime = donationDuration === 'one-time'; const walletlabel = `${t( isOneTime ? 'donate.wallet-label' : 'donate.wallet-label-1', { usd: donationAmount / 100 } @@ -352,31 +309,29 @@ class DonateForm extends Component { handlePaymentButtonLoad={this.handlePaymentButtonLoad} label={walletlabel} onDonationStateChange={this.onDonationStateChange} - postStripeDonation={this.postStripeDonation} + postPayment={this.postPayment} refreshErrorMessage={t('donate.refresh-needed')} theme={priorityTheme} /> {(!loading.stripe || !loading.paypal) && ( - + )} {showMinimalPayments && ( <>
{t('donate.or-card')}
{ - executeGA({ - type: 'event', - data: { - category: 'Donation', - action: `Modal ${action}`, - label: duration, - value: amount - } - }); + const handleProcessing = () => { setCloseLabel(true); }; @@ -93,12 +83,10 @@ function DonateModal({ const getDonationText = () => { const donationDuration = modalDefaultDonation.donationDuration; switch (donationDuration) { - case 'onetime': + case 'one-time': return {t('donate.duration')}; case 'month': return {t('donate.duration-2')}; - case 'year': - return {t('donate.duration-3')}; default: return {t('donate.duration-4')}; } @@ -158,6 +146,7 @@ function DonateModal({ diff --git a/client/src/components/Donation/patreon-button.tsx b/client/src/components/Donation/patreon-button.tsx index 7ea2702dcf1..d40b355e1c3 100644 --- a/client/src/components/Donation/patreon-button.tsx +++ b/client/src/components/Donation/patreon-button.tsx @@ -1,21 +1,23 @@ import React from 'react'; import { donationUrls, - patreonDefaultPledgeAmount + patreonDefaultPledgeAmount, + PaymentProvider } from '../../../../config/donation-settings'; import envData from '../../../../config/env.json'; import PatreonLogo from '../../assets/images/components/patreon-logo'; +import { PostPayment } from './types'; const { patreonClientId }: { patreonClientId: string | null } = envData as { patreonClientId: string | null; }; interface PatreonButtonProps { - postPatreonRedirect: () => void; + postPayment: (arg0: PostPayment) => void; } const PatreonButton = ({ - postPatreonRedirect + postPayment }: PatreonButtonProps): JSX.Element | null => { if ( !patreonClientId || @@ -36,7 +38,7 @@ const PatreonButton = ({ className='patreon-button link-button' data-patreon-widget-type='become-patron-button' href={href} - onClick={postPatreonRedirect} + onClick={() => postPayment({ paymentProvider: PaymentProvider.Patreon })} rel='noreferrer' target='_blank' > diff --git a/client/src/components/Donation/paypal-button-script-loader.tsx b/client/src/components/Donation/paypal-button-script-loader.tsx index 501164c4754..aa14fc5f724 100644 --- a/client/src/components/Donation/paypal-button-script-loader.tsx +++ b/client/src/components/Donation/paypal-button-script-loader.tsx @@ -4,7 +4,7 @@ import ReactDOM from 'react-dom'; import { scriptLoader, scriptRemover } from '../../utils/script-loaders'; -import type { AddDonationData } from './paypal-button'; +import type { DonationApprovalData } from './types'; /* eslint-disable @typescript-eslint/naming-convention */ type PayPalButtonScriptLoaderProps = { @@ -30,7 +30,7 @@ type PayPalButtonScriptLoaderProps = { ) => unknown; isSubscription: boolean; onApprove: ( - data: AddDonationData, + data: DonationApprovalData, actions?: { order: { capture: () => Promise } } ) => unknown; isPaypalLoading: boolean; @@ -66,7 +66,7 @@ declare global { } } -export class PayPalButtonScriptLoader extends Component< +export default class PayPalButtonScriptLoader extends Component< PayPalButtonScriptLoaderProps, PayPalButtonScriptLoaderState > { @@ -188,7 +188,7 @@ export class PayPalButtonScriptLoader extends Component< onApprove={ isSubscription ? ( - data: AddDonationData, + data: DonationApprovalData, actions: { order: { capture: () => Promise } } ) => onApprove(data, actions) : ( diff --git a/client/src/components/Donation/paypal-button.test.tsx b/client/src/components/Donation/paypal-button.test.tsx deleted file mode 100644 index 5c60f0ec5d3..00000000000 --- a/client/src/components/Donation/paypal-button.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { render } from '@testing-library/react'; -import React from 'react'; -import { Themes } from '../settings/theme'; - -import { PaypalButton } from './paypal-button'; - -const commonProps = { - donationAmount: 500, - donationDuration: 'month', - handleProcessing: () => null, - isDonating: false, - onDonationStateChange: () => null, - isPaypalLoading: true, - t: jest.fn(), - theme: Themes.Night, - handlePaymentButtonLoad: jest.fn(), - isMinimalForm: true -}; - -const donationData = { - redirecting: false, - processing: false, - success: false, - error: null -}; - -jest.mock('../../analytics'); - -describe('', () => { - it('does not call addDonate api on payment approval when user is not signed ', () => { - const ref = React.createRef(); - const isSubscription = true; - const addDonation = jest.fn(); - render( - - ); - - ref.current?.handleApproval(donationData, isSubscription); - expect(addDonation).toBeCalledTimes(0); - }); - it('calls addDonate api on payment approval when user is signed in', () => { - const ref = React.createRef(); - const isSubscription = true; - const addDonation = jest.fn(); - render( - - ); - - ref.current?.handleApproval(donationData, isSubscription); - expect(addDonation).toBeCalledTimes(1); - }); -}); diff --git a/client/src/components/Donation/paypal-button.tsx b/client/src/components/Donation/paypal-button.tsx index 7d51b8063a7..e6b3167e82f 100644 --- a/client/src/components/Donation/paypal-button.tsx +++ b/client/src/components/Donation/paypal-button.tsx @@ -5,23 +5,23 @@ import { createSelector } from 'reselect'; import { paypalConfigurator, paypalConfigTypes, - defaultDonation + defaultDonation, + PaymentProvider } from '../../../../config/donation-settings'; import envData from '../../../../config/env.json'; import { userSelector, signInLoadingSelector } from '../../redux/selectors'; import { Themes } from '../settings/theme'; -import { PayPalButtonScriptLoader } from './paypal-button-script-loader'; +import { + DonationApprovalData, + PostPayment, + DonationDuration, + DonationAmount +} from './types'; +import PayPalButtonScriptLoader from './paypal-button-script-loader'; type PaypalButtonProps = { - addDonation: (data: AddDonationData) => void; - isSignedIn: boolean; - donationAmount: number; - donationDuration: string; - handleProcessing: ( - duration: string, - amount: number, - action: string - ) => unknown; + donationAmount: DonationAmount; + donationDuration: DonationDuration; isDonating: boolean; onDonationStateChange: ({ redirecting, @@ -35,13 +35,13 @@ type PaypalButtonProps = { error: string | null; }) => void; isPaypalLoading: boolean; - skipAddDonation?: boolean; t: (label: string) => string; ref?: Ref; theme: Themes; isSubscription?: boolean; handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void; isMinimalForm: boolean | undefined; + postPayment: (arg0: PostPayment) => void; }; type PaypalButtonState = { @@ -50,17 +50,6 @@ type PaypalButtonState = { planId: string | null; }; -export interface AddDonationData { - redirecting: boolean; - processing: boolean; - success: boolean; - error: string | null; - loading?: { - stripe: boolean; - paypal: boolean; - }; -} - const { paypalClientId, deploymentEnv @@ -82,7 +71,6 @@ export class PaypalButton extends Component< }; constructor(props: PaypalButtonProps) { super(props); - this.handleApproval = this.handleApproval.bind(this); } static getDerivedStateFromProps( @@ -90,8 +78,8 @@ export class PaypalButton extends Component< ): PaypalButtonState { const { donationAmount, donationDuration } = props; const configurationObj: { - amount: number; - duration: string; + amount: DonationAmount; + duration: DonationDuration; planId: string | null; } = paypalConfigurator( donationAmount, @@ -105,30 +93,10 @@ export class PaypalButton extends Component< return { ...configurationObj }; } - handleApproval = (data: AddDonationData, isSubscription: boolean): void => { - const { amount, duration } = this.state; - const { isSignedIn = false } = this.props; - - // If the user is signed in and the payment is subscritipn call the api - if (isSignedIn && isSubscription) { - this.props.addDonation(data); - } - - this.props.handleProcessing(duration, amount, 'Paypal payment submission'); - - // Show success anytime because the payment has gone through paypal - this.props.onDonationStateChange({ - redirecting: false, - processing: false, - success: true, - error: data.error ? data.error : null - }); - }; - render(): JSX.Element | null { const { duration, planId, amount } = this.state; const { t, theme, isPaypalLoading, isMinimalForm } = this.props; - const isSubscription = duration !== 'onetime'; + const isSubscription = duration !== 'one-time'; const buttonColor = theme === Themes.Night ? 'white' : 'gold'; if (!paypalClientId) { return null; @@ -177,8 +145,11 @@ export class PaypalButton extends Component< isMinimalForm={isMinimalForm} isPaypalLoading={isPaypalLoading} isSubscription={isSubscription} - onApprove={(data: AddDonationData) => { - this.handleApproval(data, isSubscription); + onApprove={(data: DonationApprovalData) => { + this.props.postPayment({ + paymentProvider: PaymentProvider.Paypal, + data + }); }} onCancel={() => { this.props.onDonationStateChange({ diff --git a/client/src/components/Donation/stripe-card-form.tsx b/client/src/components/Donation/stripe-card-form.tsx index deb1d9c0f2e..683c118480a 100644 --- a/client/src/components/Donation/stripe-card-form.tsx +++ b/client/src/components/Donation/stripe-card-form.tsx @@ -9,29 +9,21 @@ import { import { loadStripe } from '@stripe/stripe-js'; import type { StripeCardNumberElementChangeEvent, - StripeCardExpiryElementChangeEvent, - PaymentIntentResult + StripeCardExpiryElementChangeEvent } from '@stripe/stripe-js'; import React, { useState } from 'react'; +import { PaymentProvider } from '../../../../config/donation-settings'; import envData from '../../../../config/env.json'; import { Themes } from '../settings/theme'; -import { AddDonationData } from './paypal-button'; +import { DonationApprovalData, PostPayment } from './types'; import SecurityLockIcon from './security-lock-icon'; const { stripePublicKey }: { stripePublicKey: string | null } = envData; -export type HandleAuthentication = ( - clientSecret: string, - paymentMethod: string -) => Promise; - interface FormPropTypes { - onDonationStateChange: (donationState: AddDonationData) => void; - postStripeCardDonation: ( - paymentMethodId: string, - handleAuthentication: HandleAuthentication - ) => void; + onDonationStateChange: (donationState: DonationApprovalData) => void; + postPayment: (arg0: PostPayment) => void; t: (label: string) => string; theme: Themes; processing: boolean; @@ -50,7 +42,7 @@ const StripeCardForm = ({ theme, t, onDonationStateChange, - postStripeCardDonation, + postPayment, processing, isVariantA }: FormPropTypes): JSX.Element => { @@ -124,7 +116,11 @@ const StripeCardForm = ({ error: t('donate.went-wrong') }); } else if (paymentMethod) - postStripeCardDonation(paymentMethod.id, handleAuthentication); + postPayment({ + paymentProvider: PaymentProvider.StripeCard, + paymentMethodId: paymentMethod.id, + handleAuthentication + }); } } return setTokenizing(false); diff --git a/client/src/components/Donation/types.ts b/client/src/components/Donation/types.ts new file mode 100644 index 00000000000..956c8da1518 --- /dev/null +++ b/client/src/components/Donation/types.ts @@ -0,0 +1,37 @@ +import type { Token, PaymentIntentResult } from '@stripe/stripe-js'; + +export type PaymentContext = 'modal' | 'donate page' | 'certificate'; +export type PaymentProvider = 'patreon' | 'paypal' | 'stripe' | 'stripe card'; + +export type HandleAuthentication = ( + clientSecret: string, + paymentMethod: string +) => Promise; + +export type DonationAmount = 500 | 1000 | 2000 | 3000 | 4000 | 5000; +export type DonationDuration = 'one-time' | 'month'; +export interface DonationConfig { + donationAmount: DonationAmount; + donationDuration: DonationDuration; +} + +export interface PostPayment { + paymentProvider: PaymentProvider; + data?: DonationApprovalData; + token?: Token; + payerEmail?: string | undefined; + payerName?: string | undefined; + paymentMethodId?: string; + handleAuthentication?: HandleAuthentication; +} + +export interface DonationApprovalData { + redirecting: boolean; + processing: boolean; + success: boolean; + error: string | null; + loading?: { + stripe: boolean; + paypal: boolean; + }; +} diff --git a/client/src/components/Donation/walletsButton.tsx b/client/src/components/Donation/walletsButton.tsx index c3831744609..668b279ee60 100644 --- a/client/src/components/Donation/walletsButton.tsx +++ b/client/src/components/Donation/walletsButton.tsx @@ -8,7 +8,8 @@ import type { Token, PaymentRequest } from '@stripe/stripe-js'; import React, { useState, useEffect } from 'react'; import envData from '../../../../config/env.json'; import { Themes } from '../settings/theme'; -import { AddDonationData } from './paypal-button'; +import { PaymentProvider } from '../../../../config/donation-settings'; +import { DonationApprovalData, PostPayment } from './types'; const { stripePublicKey }: { stripePublicKey: string | null } = envData; @@ -16,12 +17,8 @@ interface WrapperProps { label: string; amount: number; theme: Themes; - postStripeDonation: ( - token: Token, - payerEmail: string | undefined, - payerName: string | undefined - ) => void; - onDonationStateChange: (donationState: AddDonationData) => void; + postPayment: (arg0: PostPayment) => void; + onDonationStateChange: (donationState: DonationApprovalData) => void; refreshErrorMessage: string; handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void; } @@ -35,7 +32,7 @@ const WalletsButton = ({ amount, theme, refreshErrorMessage, - postStripeDonation, + postPayment, onDonationStateChange, handlePaymentButtonLoad }: WalletsButtonProps) => { @@ -63,7 +60,12 @@ const WalletsButton = ({ const { token, payerEmail, payerName } = event; setToken(token); event.complete('success'); - postStripeDonation(token, payerEmail, payerName); + postPayment({ + paymentProvider: PaymentProvider.Stripe, + token, + payerEmail, + payerName + }); }); void pr.canMakePayment().then(canMakePaymentRes => { @@ -74,7 +76,7 @@ const WalletsButton = ({ checkpaymentPossiblity(false); } }); - }, [label, amount, stripe, postStripeDonation, handlePaymentButtonLoad]); + }, [label, amount, stripe, postPayment, handlePaymentButtonLoad]); const displayRefreshError = (): void => { onDonationStateChange({ diff --git a/client/src/pages/donate.tsx b/client/src/pages/donate.tsx index aaed7962775..c2991783c09 100644 --- a/client/src/pages/donate.tsx +++ b/client/src/pages/donate.tsx @@ -19,6 +19,7 @@ import { Spacer, Loader } from '../components/helpers'; import CampersImage from '../components/landing/components/campers-image'; import { executeGA } from '../redux/actions'; import { signInLoadingSelector, userSelector } from '../redux/selectors'; +import { PaymentContext } from '../../../config/donation-settings'; export interface ExecuteGaArg { type: string; @@ -68,18 +69,6 @@ function DonatePage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - function handleProcessing(duration: string, amount: number, action: string) { - executeGA({ - type: 'event', - data: { - category: 'Donation', - action: `donate page ${action}`, - label: duration, - value: amount - } - }); - } - return showLoading ? ( ) : ( @@ -110,7 +99,7 @@ function DonatePage({ - + diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index b5c307e99b6..1b487225651 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -27,15 +27,13 @@ export const actionTypes = createTypes( 'updateFailed', 'updateDonationFormState', 'updateUserToken', + 'postChargeProcessing', ...createAsyncTypes('fetchUser'), - ...createAsyncTypes('addDonation'), - ...createAsyncTypes('createStripeSession'), - ...createAsyncTypes('postChargeStripe'), + ...createAsyncTypes('postCharge'), ...createAsyncTypes('fetchProfileForUser'), ...createAsyncTypes('acceptTerms'), ...createAsyncTypes('showCert'), ...createAsyncTypes('reportUser'), - ...createAsyncTypes('postChargeStripeCard'), ...createAsyncTypes('deleteUserToken'), ...createAsyncTypes('saveChallenge') ], diff --git a/client/src/redux/actions.js b/client/src/redux/actions.js index a540a71d58c..47a3d923eed 100644 --- a/client/src/redux/actions.js +++ b/client/src/redux/actions.js @@ -53,28 +53,12 @@ export const fetchUser = createAction(actionTypes.fetchUser); export const fetchUserComplete = createAction(actionTypes.fetchUserComplete); export const fetchUserError = createAction(actionTypes.fetchUserError); -export const addDonation = createAction(actionTypes.addDonation); -export const addDonationComplete = createAction( - actionTypes.addDonationComplete -); -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 postChargeStripeCard = createAction( - actionTypes.postChargeStripeCard -); -export const postChargeStripeCardComplete = createAction( - actionTypes.postChargeStripeCardComplete -); -export const postChargeStripeCardError = createAction( - actionTypes.postChargeStripeCardError +export const postCharge = createAction(actionTypes.postCharge); +export const postChargeProcessing = createAction( + actionTypes.postChargeProcessing ); +export const postChargeComplete = createAction(actionTypes.postChargeComplete); +export const postChargeError = createAction(actionTypes.postChargeError); export const fetchProfileForUser = createAction( actionTypes.fetchProfileForUser diff --git a/client/src/redux/donation-saga.js b/client/src/redux/donation-saga.js index 73f0a9f5822..564698bccea 100644 --- a/client/src/redux/donation-saga.js +++ b/client/src/redux/donation-saga.js @@ -14,22 +14,23 @@ import { postChargeStripe, postChargeStripeCard } from '../utils/ajax'; +import { stringifyDonationEvents } from '../utils/analyticsStrings'; +import { PaymentProvider } from '../../../config/donation-settings'; import { actionTypes as appTypes } from './action-types'; import { - addDonationComplete, - addDonationError, openDonationModal, - postChargeStripeCardComplete, - postChargeStripeCardError, - postChargeStripeComplete, - postChargeStripeError, + postChargeComplete, + postChargeProcessing, + postChargeError, preventBlockDonationRequests, - preventProgressDonationRequests + preventProgressDonationRequests, + executeGA } from './actions'; import { isDonatingSelector, recentlyClaimedBlockSelector, - shouldRequestDonationSelector + shouldRequestDonationSelector, + isSignedInSelector } from './selectors'; const defaultDonationErrorMessage = i18next.t('donate.error-2'); @@ -49,33 +50,77 @@ function* showDonateModalSaga() { } } -function* addDonationSaga({ payload }) { - try { - yield call(addDonation, payload); - yield put(addDonationComplete()); - yield call(setDonationCookie); - } catch (error) { - const data = - error.response && error.response.data - ? error.response.data - : { - message: defaultDonationErrorMessage - }; - yield put(addDonationError(data.message)); +export function* postChargeSaga({ + payload, + payload: { + paymentProvider, + paymentContext, + amount, + duration, + handleAuthentication, + paymentMethodId } -} - -function* postChargeStripeSaga({ payload }) { +}) { try { - yield call(postChargeStripe, payload); - yield put(postChargeStripeComplete()); - yield call(setDonationCookie); + if (paymentProvider !== PaymentProvider.Patreon) { + yield put(postChargeProcessing()); + } + + if (paymentProvider === PaymentProvider.Stripe) { + yield call(postChargeStripe, payload); + } else if (paymentProvider === PaymentProvider.StripeCard) { + const optimizedPayload = { paymentMethodId, amount, duration }; + const response = yield call(postChargeStripeCard, optimizedPayload); + const error = response?.data?.error; + if (error) { + yield stripeCardErrorHandler( + error, + handleAuthentication, + error.client_secret, + response.paymentMethodId, + optimizedPayload + ); + + //if the authentication does not throw an error, add a donation + yield call(addDonation, { amount, duration }); + } + } else if (paymentProvider === PaymentProvider.Paypal) { + // If the user is signed in and the payment goes through call api + let isSignedIn = yield select(isSignedInSelector); + // look into skip add donation + // what to do with "data" that comes throug + if (isSignedIn) yield call(addDonation, { amount, duration }); + } + if ( + [ + PaymentProvider.Paypal, + PaymentProvider.Stripe, + PaymentProvider.StripeCard + ].includes(paymentProvider) + ) { + yield put(postChargeComplete()); + yield call(setDonationCookie); + } + yield put( + executeGA({ + type: 'event', + data: { + category: + paymentProvider === PaymentProvider.Patreon + ? 'Donation Related' + : 'Donation', + action: stringifyDonationEvents(paymentContext, paymentProvider), + label: duration, + value: amount + } + }) + ); } catch (error) { const err = error.response && error.response.data ? error.response.data.error : defaultDonationErrorMessage; - yield put(postChargeStripeError(err)); + yield put(postChargeError(err)); } } @@ -99,40 +144,16 @@ function* stripeCardErrorHandler( } } -function* postChargeStripeCardSaga({ - payload: { paymentMethodId, amount, duration, handleAuthentication } -}) { - try { - const optimizedPayload = { paymentMethodId, amount, duration }; - const { - data: { error } - } = yield call(postChargeStripeCard, optimizedPayload); - if (error) { - yield stripeCardErrorHandler( - error, - handleAuthentication, - error.client_secret, - paymentMethodId, - optimizedPayload - ); - } - yield call(addDonation, optimizedPayload); - yield put(postChargeStripeCardComplete()); - yield call(setDonationCookie); - } catch (error) { - const errorMessage = error.message || defaultDonationErrorMessage; - yield put(postChargeStripeCardError(errorMessage)); - } -} - -function* setDonationCookie() { - const isDonating = yield select(isDonatingSelector); - const isDonorCookieSet = document.cookie - .split(';') - .some(item => item.trim().startsWith('isDonor=true')); - if (isDonating) { - if (!isDonorCookieSet) { - document.cookie = 'isDonor=true'; +export function* setDonationCookie() { + if (document?.cookie) { + const isDonating = yield select(isDonatingSelector); + const isDonorCookieSet = document.cookie + .split(';') + .some(item => item.trim().startsWith('isDonor=true')); + if (isDonating) { + if (!isDonorCookieSet) { + document.cookie = 'isDonor=true'; + } } } } @@ -140,9 +161,7 @@ function* setDonationCookie() { export function createDonationSaga(types) { return [ takeEvery(types.tryToShowDonationModal, showDonateModalSaga), - takeEvery(types.addDonation, addDonationSaga), - takeLeading(types.postChargeStripe, postChargeStripeSaga), - takeLeading(types.postChargeStripeCard, postChargeStripeCardSaga), + takeLeading(types.postCharge, postChargeSaga), takeEvery(types.fetchUserComplete, setDonationCookie) ]; } diff --git a/client/src/redux/donation-saga.test.js b/client/src/redux/donation-saga.test.js new file mode 100644 index 00000000000..c093a432845 --- /dev/null +++ b/client/src/redux/donation-saga.test.js @@ -0,0 +1,130 @@ +import { expectSaga } from 'redux-saga-test-plan'; +import { + postChargeStripe, + postChargeStripeCard, + addDonation +} from '../utils/ajax'; +import { postChargeSaga, setDonationCookie } from './donation-saga.js'; +import { postChargeComplete, postChargeProcessing, executeGA } from './actions'; + +jest.mock('../utils/ajax'); +jest.mock('../analytics'); + +const postChargeDataMock = { + payload: { + paymentProvider: 'stripe', + paymentContext: 'donate page', + amount: '500', + duration: 'monthly', + handleAuthentication: jest.fn(), + paymentMethodId: '123456' + } +}; + +const analyticsDataMock = { + type: 'event', + data: { + category: 'Donation', + action: 'Donate Page Stripe Payment Submission', + label: 'monthly', + value: '500' + } +}; + +describe('donation-saga', () => { + it('calls postChargeStrip for Stripe', () => { + return expectSaga(postChargeSaga, postChargeDataMock) + .put(postChargeProcessing()) + .call(postChargeStripe, postChargeDataMock.payload) + .put(postChargeComplete()) + .call(setDonationCookie) + .put(executeGA(analyticsDataMock)) + .run(); + }); + + it('calls postChargeStripCard for Stripe Card', () => { + const stripeCardDataMock = { + payload: { ...postChargeDataMock.payload, paymentProvider: 'stripe card' } + }; + + const stripeCardAnalyticsDataMock = analyticsDataMock; + stripeCardAnalyticsDataMock.data.action = + 'Donate Page Stripe Card Payment Submission'; + + const { paymentMethodId, amount, duration } = stripeCardDataMock.payload; + const optimizedPayload = { paymentMethodId, amount, duration }; + return expectSaga(postChargeSaga, stripeCardDataMock) + .put(postChargeProcessing()) + .call(postChargeStripeCard, optimizedPayload) + .put(postChargeComplete()) + .call(setDonationCookie) + .put(executeGA(stripeCardAnalyticsDataMock)) + .run(); + }); + + it('calls addDonate for Paypal if user signed in', () => { + const paypalDataMock = { + payload: { ...postChargeDataMock.payload, paymentProvider: 'paypal' } + }; + + const paypalAnalyticsDataMock = analyticsDataMock; + paypalAnalyticsDataMock.data.action = + 'Donate Page Paypal Payment Submission'; + + const storeMock = { + app: { + appUsername: 'devuser' + } + }; + + const { amount, duration } = paypalDataMock.payload; + return expectSaga(postChargeSaga, paypalDataMock) + .withState(storeMock) + .put(postChargeProcessing()) + .call(addDonation, { amount, duration }) + .put(postChargeComplete()) + .call(setDonationCookie) + .put(executeGA(paypalAnalyticsDataMock)) + .run(); + }); + + it('does not call addDonate for Paypal if user not signed in', () => { + const paypalDataMock = { + payload: { ...postChargeDataMock.payload, paymentProvider: 'paypal' } + }; + + const paypalAnalyticsDataMock = analyticsDataMock; + paypalAnalyticsDataMock.data.action = + 'Donate Page Paypal Payment Submission'; + + const storeMock = { + app: {} + }; + + return expectSaga(postChargeSaga, paypalDataMock) + .withState(storeMock) + .put(postChargeProcessing()) + .not.call.fn(addDonation) + .put(postChargeComplete()) + .call(setDonationCookie) + .put(executeGA(paypalAnalyticsDataMock)) + .run(); + }); + + it('does not call api for Patreon', () => { + const patreonDataMock = { + payload: { ...postChargeDataMock.payload, paymentProvider: 'patreon' } + }; + + const patreonAnalyticsDataMock = analyticsDataMock; + patreonAnalyticsDataMock.data.action = + 'Donate Page Patreon Payment Redirection'; + patreonAnalyticsDataMock.data.category = 'Donation Related'; + return expectSaga(postChargeSaga, patreonDataMock) + .not.call.fn(addDonation) + .not.call.fn(postChargeStripeCard) + .not.call.fn(postChargeStripe) + .put(executeGA(patreonAnalyticsDataMock)) + .run(); + }); +}); diff --git a/client/src/redux/index.js b/client/src/redux/index.js index ac8aefe7902..b520e2c03c9 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -130,11 +130,11 @@ export const reducer = handleActions( ...state, donationFormState: { ...state.donationFormState, ...payload } }), - [actionTypes.addDonation]: state => ({ + [actionTypes.postChargeProcessing]: state => ({ ...state, donationFormState: { ...defaultDonationFormState, processing: true } }), - [actionTypes.addDonationComplete]: state => { + [actionTypes.postChargeComplete]: state => { const { appUsername } = state; return { ...state, @@ -149,56 +149,11 @@ export const reducer = handleActions( donationFormState: { ...defaultDonationFormState, success: true } }; }, - [actionTypes.addDonationError]: (state, { payload }) => ({ + [actionTypes.postChargeError]: (state, { payload }) => ({ ...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.postChargeStripeCard]: state => ({ - ...state, - donationFormState: { ...defaultDonationFormState, processing: true } - }), - [actionTypes.postChargeStripeCardComplete]: state => { - const { appUsername } = state; - return { - ...state, - user: { - ...state.user, - [appUsername]: { - ...state.user[appUsername], - isDonating: true - } - }, - - donationFormState: { ...defaultDonationFormState, success: true } - }; - }, - [actionTypes.postChargeStripeCardError]: (state, { payload }) => ({ - ...state, - donationFormState: { ...defaultDonationFormState, error: payload } - }), [actionTypes.fetchUser]: state => ({ ...state, userFetchState: { ...defaultFetchState } diff --git a/client/src/utils/analyticsStrings.test.ts b/client/src/utils/analyticsStrings.test.ts new file mode 100644 index 00000000000..96cf47ce145 --- /dev/null +++ b/client/src/utils/analyticsStrings.test.ts @@ -0,0 +1,14 @@ +import { stringifyDonationEvents } from './analyticsStrings'; + +describe('Analytics donation strings', () => { + it('Should return correct string for modal patreon payment', () => { + expect(stringifyDonationEvents('modal', 'patreon')).toEqual( + 'Modal Patreon Payment Redirection' + ); + }); + it('Should return correct string for modal donate page stripe card payment', () => { + expect(stringifyDonationEvents('donate page', 'stripe card')).toEqual( + 'Donate Page Stripe Card Payment Submission' + ); + }); +}); diff --git a/client/src/utils/analyticsStrings.ts b/client/src/utils/analyticsStrings.ts new file mode 100644 index 00000000000..f5f559c08a9 --- /dev/null +++ b/client/src/utils/analyticsStrings.ts @@ -0,0 +1,15 @@ +import { PaymentContext, PaymentProvider } from '../components/Donation/types'; + +export function stringifyDonationEvents( + paymentContext: PaymentContext, + paymentProvider: PaymentProvider +): string { + const donationString = `${paymentContext} ${paymentProvider} payment ${ + paymentProvider === 'patreon' ? 'redirection' : 'submission' + }`; + + // return title case + return donationString.replace(/(^\w{1})|(\s+\w{1})/g, letter => + letter.toUpperCase() + ); +} diff --git a/config/donation-settings.js b/config/donation-settings.ts similarity index 58% rename from config/donation-settings.js rename to config/donation-settings.ts index bdf7886dcd3..4196a53f475 100644 --- a/config/donation-settings.js +++ b/config/donation-settings.ts @@ -1,26 +1,32 @@ // Configuration for client side -const durationsConfig = { +import { DonationConfig } from '../client/src/components/Donation/types'; + +export const durationsConfig: { + month: 'monthly'; + onetime: 'one-time'; +} = { month: 'monthly', onetime: 'one-time' }; -const amountsConfig = { + +export const amountsConfig = { month: [1000, 2000, 3000, 4000, 5000], onetime: [2500, 5000, 7500, 10000, 15000] }; -const defaultAmount = { +export const defaultAmount: { month: 500; onetime: 7500 } = { month: 500, onetime: 7500 }; -const defaultDonation = { - donationAmount: defaultAmount['month'], +export const defaultDonation: DonationConfig = { + donationAmount: defaultAmount.month, donationDuration: 'month' }; -const modalDefaultDonation = { +export const modalDefaultDonation: DonationConfig = { donationAmount: 500, donationDuration: 'month' }; -const onetimeSKUConfig = { +export const onetimeSKUConfig = { live: [ { amount: '15000', id: 'sku_IElisJHup0nojP' }, { amount: '10000', id: 'sku_IEliodY88lglPk' }, @@ -38,9 +44,9 @@ const onetimeSKUConfig = { }; // Configuration for server side -const durationKeysConfig = ['month', 'onetime']; -const donationOneTimeConfig = [100000, 25000, 6000]; -const donationSubscriptionConfig = { +export const durationKeysConfig = ['month', 'one-time']; +export const donationOneTimeConfig = [100000, 25000, 6000]; +export const donationSubscriptionConfig = { duration: { month: 'Monthly' }, @@ -51,7 +57,7 @@ const donationSubscriptionConfig = { // Shared paypal configuration // keep the 5 dollars for the modal -const paypalConfigTypes = { +export const paypalConfigTypes = { live: { month: { 500: { planId: 'P-1L11422374370240ULZKX3PA' }, @@ -74,42 +80,51 @@ const paypalConfigTypes = { } }; -const paypalConfigurator = (donationAmount, donationDuration, paypalConfig) => { - if (donationDuration === 'onetime') { +export const paypalConfigurator = ( + donationAmount: 500 | 1000 | 2000 | 3000 | 4000 | 5000, + donationDuration: 'one-time' | 'month', + paypalConfig: { + month: { + 500: { planId: string }; + 1000: { planId: string }; + 2000: { planId: string }; + 3000: { planId: string }; + 4000: { planId: string }; + 5000: { planId: string }; + }; + } +) => { + if (donationDuration === 'one-time') { return { amount: donationAmount, duration: donationDuration, planId: null }; } return { amount: donationAmount, duration: donationDuration, - planId: paypalConfig[donationDuration]['' + donationAmount].planId + planId: paypalConfig[donationDuration][donationAmount].planId }; }; -const donationUrls = { +export const donationUrls = { successUrl: 'https://www.freecodecamp.org/news/thank-you-for-donating/', cancelUrl: 'https://freecodecamp.org/donate' }; -const patreonDefaultPledgeAmount = 500; +export const patreonDefaultPledgeAmount = 500; -const aBTestConfig = { +export const aBTestConfig = { isTesting: true, type: 'secureIconButtonOnly' }; -module.exports = { - durationsConfig, - amountsConfig, - defaultAmount, - defaultDonation, - durationKeysConfig, - donationOneTimeConfig, - donationSubscriptionConfig, - modalDefaultDonation, - onetimeSKUConfig, - paypalConfigTypes, - paypalConfigurator, - donationUrls, - patreonDefaultPledgeAmount, - aBTestConfig -}; +export enum PaymentContext { + Modal = 'modal', + DonatePage = 'donate page', + Certificate = 'certificate' +} + +export enum PaymentProvider { + Paypal = 'paypal', + Patreon = 'patreon', + Stripe = 'stripe', + StripeCard = 'stripe card' +}