feat(donation): simplify donation client (#46379)

* feat: unify post payment actions

* feat: handle stripe card error and add donation after auth

* feat: add donation saga stripe test

* feat: add more coverage to stripe tests

* feat: add initial stripe card saga test

* feat: finalize initial stripe card saga test

* feat: add patreon test saga

* feat: test clean up

* feat: do not show processing for Patreon

* feat: normalize donation settings

* feat: turn payment provider/contex to enum

* feat: remove donation-settings.js

* fix: git ignore generated config

* fix: ignore the generate config from everything

* fix: remove types.js

* fix: update linting to include types.js

Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
Ahmad Abdolsaheb
2022-12-20 15:33:06 +03:00
committed by GitHub
parent 4f9122561d
commit bff61255f9
25 changed files with 474 additions and 472 deletions

View File

@@ -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

2
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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];
}

View File

@@ -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",

View File

@@ -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}
/>
</Col>
</Row>

View File

@@ -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<DonateFormProps, DonateFormComponentState> {
@@ -124,27 +128,20 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
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<DonateFormProps, DonateFormComponentState> {
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<DonateFormProps, DonateFormComponentState> {
});
}
// 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<DonateFormProps, DonateFormComponentState> {
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<DonateFormProps, DonateFormComponentState> {
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<DonateFormProps, DonateFormComponentState> {
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<DonateFormProps, DonateFormComponentState> {
const { donationAmount, donationDuration } = this.state;
const {
donationFormState: { loading, processing },
handleProcessing,
addDonation,
defaultTheme,
theme,
t,
@@ -327,7 +284,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
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<DonateFormProps, DonateFormComponentState> {
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
label={walletlabel}
onDonationStateChange={this.onDonationStateChange}
postStripeDonation={this.postStripeDonation}
postPayment={this.postPayment}
refreshErrorMessage={t('donate.refresh-needed')}
theme={priorityTheme}
/>
<PaypalButton
addDonation={addDonation}
donationAmount={donationAmount}
donationDuration={donationDuration}
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
handleProcessing={handleProcessing}
postPayment={this.postPayment}
isMinimalForm={showMinimalPayments}
isPaypalLoading={loading.paypal}
isSignedIn={isSignedIn}
onDonationStateChange={this.onDonationStateChange}
theme={priorityTheme}
/>
{(!loading.stripe || !loading.paypal) && (
<PatreonButton postPatreonRedirect={this.postPatreonRedirect} />
<PatreonButton postPayment={this.postPayment} />
)}
{showMinimalPayments && (
<>
<div className='separator'>{t('donate.or-card')}</div>
<StripeCardForm
onDonationStateChange={this.onDonationStateChange}
postStripeCardDonation={this.postStripeCardDonation}
postPayment={this.postPayment}
processing={processing}
t={t}
theme={priorityTheme}

View File

@@ -6,7 +6,10 @@ import { connect } from 'react-redux';
import { goToAnchor } from 'react-scrollable-anchor';
import { bindActionCreators, Dispatch, AnyAction } from 'redux';
import { createSelector } from 'reselect';
import { modalDefaultDonation } from '../../../../config/donation-settings';
import {
modalDefaultDonation,
PaymentContext
} from '../../../../config/donation-settings';
import Cup from '../../assets/icons/cup';
import Heart from '../../assets/icons/heart';
@@ -56,20 +59,7 @@ function DonateModal({
}: DonateModalProps): JSX.Element {
const [closeLabel, setCloseLabel] = React.useState(false);
const { t } = useTranslation();
const handleProcessing = (
duration: string,
amount: number,
action: string
) => {
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 <b>{t('donate.duration')}</b>;
case 'month':
return <b>{t('donate.duration-2')}</b>;
case 'year':
return <b>{t('donate.duration-3')}</b>;
default:
return <b>{t('donate.duration-4')}</b>;
}
@@ -158,6 +146,7 @@ function DonateModal({
<DonateForm
handleProcessing={handleProcessing}
isMinimalForm={true}
paymentContext={PaymentContext.Modal}
/>
</Col>
</Row>

View File

@@ -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'
>

View File

@@ -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> } }
) => 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<unknown> } }
) => onApprove(data, actions)
: (

View File

@@ -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('<Paypal Button/>', () => {
it('does not call addDonate api on payment approval when user is not signed ', () => {
const ref = React.createRef<PaypalButton>();
const isSubscription = true;
const addDonation = jest.fn();
render(
<PaypalButton
{...commonProps}
addDonation={addDonation}
isSignedIn={false}
ref={ref}
/>
);
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<PaypalButton>();
const isSubscription = true;
const addDonation = jest.fn();
render(
<PaypalButton
{...commonProps}
addDonation={addDonation}
isSignedIn={true}
ref={ref}
/>
);
ref.current?.handleApproval(donationData, isSubscription);
expect(addDonation).toBeCalledTimes(1);
});
});

View File

@@ -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<PaypalButton>;
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({

View File

@@ -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<PaymentIntentResult | { error: { type: string } }>;
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);

View File

@@ -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<PaymentIntentResult | { error: { type: string } }>;
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;
};
}

View File

@@ -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({

View File

@@ -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 ? (
<Loader fullScreen={true} />
) : (
@@ -110,7 +99,7 @@ function DonatePage({
<DonationText />
<Row>
<Col xs={12}>
<DonateForm handleProcessing={handleProcessing} />
<DonateForm paymentContext={PaymentContext.DonatePage} />
</Col>
</Row>
<Spacer size={3} />

View File

@@ -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')
],

View File

@@ -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

View File

@@ -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)
];
}

View File

@@ -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();
});
});

View File

@@ -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 }

View File

@@ -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'
);
});
});

View File

@@ -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()
);
}

View File

@@ -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'
}