diff --git a/api-server/src/server/boot/donate.js b/api-server/src/server/boot/donate.js index 56dad4c230..29268230ff 100644 --- a/api-server/src/server/boot/donate.js +++ b/api-server/src/server/boot/donate.js @@ -1,12 +1,14 @@ import debug from 'debug'; import Stripe from 'stripe'; + import { donationSubscriptionConfig } from '../../../../config/donation-settings'; import keys from '../../../../config/secrets'; import { getAsyncPaypalToken, verifyWebHook, updateUser, - verifyWebHookType + verifyWebHookType, + createStripeCardDonation } from '../utils/donation'; import { validStripeForm } from '../utils/stripeHelpers'; @@ -26,6 +28,18 @@ export default function donateBoot(app, done) { }); } + async function handleStripeCardDonation(req, res) { + return createStripeCardDonation(req, res, stripe, app).catch(err => { + if (err.type === 'AlreadyDonatingError') + return res.status(402).send({ error: err }); + if (err.type === 'InvalidRequest') + return res.status(400).send({ error: err }); + return res.status(500).send({ + error: 'Donation failed due to a server error.' + }); + }); + } + function createStripeDonation(req, res) { const { user, body } = req; @@ -184,7 +198,6 @@ export default function donateBoot(app, done) { const stripeSecretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard'; const stripPublicInvalid = !stripeKey || stripeKey === 'pk_from_stripe_dashboard'; - const paypalSecretInvalid = !paypalKey || paypalKey === 'id_from_paypal_dashboard'; const paypalPublicInvalid = @@ -201,6 +214,7 @@ export default function donateBoot(app, done) { done(); } else { api.post('/charge-stripe', createStripeDonation); + api.post('/charge-stripe-card', handleStripeCardDonation); api.post('/add-donation', addDonation); hooks.post('/update-paypal', updatePaypal); donateRouter.use('/donate', api); diff --git a/api-server/src/server/utils/donation.js b/api-server/src/server/utils/donation.js index 8928134935..d6b7f5c9f3 100644 --- a/api-server/src/server/utils/donation.js +++ b/api-server/src/server/utils/donation.js @@ -1,6 +1,7 @@ /* eslint-disable camelcase */ import axios from 'axios'; import debug from 'debug'; +import { donationSubscriptionConfig } from '../../../../config/donation-settings'; import keys from '../../../../config/secrets'; const log = debug('fcc:boot:donate'); @@ -171,3 +172,79 @@ export async function updateUser(body, app) { type: 'UnsupportedWebhookType' }; } + +export async function createStripeCardDonation(req, res, stripe) { + const { + body: { + token: { id: tokenId }, + amount, + duration + }, + user: { name, id: userId, email }, + user + } = req; + + if (!tokenId || !amount || !duration || !name || !userId || !email) { + throw { + message: 'Request is not valid', + type: 'InvalidRequest' + }; + } + + if (user.isDonating && duration !== 'onetime') { + throw { + message: `User already has active recurring donation(s).`, + type: 'AlreadyDonatingError' + }; + } + + let customerId; + try { + const customer = await stripe.customers.create({ + email, + card: tokenId, + name + }); + customerId = customer?.id; + } catch { + throw { + type: 'customerCreationFailed', + message: 'Failed to create stripe customer' + }; + } + log(`Stripe customer with id ${customerId} created`); + + let subscriptionId; + try { + const subscription = await stripe.subscriptions.create({ + customer: customerId, + items: [ + { + plan: `${donationSubscriptionConfig.duration[ + duration + ].toLowerCase()}-donation-${amount}` + } + ] + }); + subscriptionId = subscription?.id; + } catch { + throw { + type: 'subscriptionCreationFailed', + message: 'Failed to create stripe subscription' + }; + } + log(`Stripe subscription with id ${subscriptionId} created`); + + // save Donation + let donation = { + email, + amount, + duration, + provider: 'stripe', + subscriptionId, + customerId, + startDate: new Date().toISOString() + }; + await createAsyncUserDonation(user, donation); + return res.status(200).json({ isDonating: true }); +} diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index beaaa25d2b..c5803469d0 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -319,6 +319,7 @@ "nicely-done": "Nicely done. You just completed {{block}}.", "credit-card": "Credit Card", "credit-card-2": "Or donate with a credit card:", + "or-card": "Or donate with card", "paypal": "with PayPal:", "need-email": "We need a valid email address to which we can send your donation tax receipt.", "went-wrong": "Something went wrong processing your donation. Your card has not been charged.", diff --git a/client/src/components/Donation/DonateForm.tsx b/client/src/components/Donation/DonateForm.tsx index f941b3aae2..e4c862f710 100644 --- a/client/src/components/Donation/DonateForm.tsx +++ b/client/src/components/Donation/DonateForm.tsx @@ -1,8 +1,5 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/unbound-method */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable no-nested-ternary */ - import type { Token } from '@stripe/stripe-js'; import React, { Component } from 'react'; import { withTranslation } from 'react-i18next'; @@ -25,14 +22,14 @@ import { updateDonationFormState, defaultDonationFormState, userSelector, - postChargeStripe + postChargeStripe, + postChargeStripeCard } from '../../redux'; import Spacer from '../helpers/spacer'; - import DonateCompletion from './DonateCompletion'; - import type { AddDonationData } from './PaypalButton'; import PaypalButton from './PaypalButton'; +import StripeCardForm from './stripe-card-form'; import WalletsWrapper from './walletsButton'; import './Donation.css'; @@ -51,7 +48,7 @@ type DonateFormState = { }; }; -type DonateFromComponentState = { +type DonateFormComponentState = { donationAmount: number; donationDuration: string; }; @@ -59,6 +56,11 @@ type DonateFromComponentState = { type DonateFormProps = { addDonation: (data: unknown) => unknown; postChargeStripe: (data: unknown) => unknown; + postChargeStripeCard: (data: { + token: Token; + amount: number; + duration: string; + }) => void; defaultTheme?: string; email: string; handleProcessing: (duration: string, amount: number, action: string) => void; @@ -96,10 +98,11 @@ const mapStateToProps = createSelector( const mapDispatchToProps = { addDonation, updateDonationFormState, - postChargeStripe + postChargeStripe, + postChargeStripeCard }; -class DonateForm extends Component { +class DonateForm extends Component { static displayName = 'DonateForm'; durations: { month: 'monthly'; onetime: 'one-time' }; amounts: { month: number[]; onetime: number[] }; @@ -125,6 +128,7 @@ class DonateForm extends Component { this.handleSelectDuration = this.handleSelectDuration.bind(this); this.resetDonation = this.resetDonation.bind(this); this.postStripeDonation = this.postStripeDonation.bind(this); + this.postStripeCardDonation = this.postStripeCardDonation.bind(this); this.handlePaymentButtonLoad = this.handlePaymentButtonLoad.bind(this); } @@ -217,6 +221,20 @@ class DonateForm extends Component { }); } + postStripeCardDonation(token: Token) { + const { donationAmount: amount, donationDuration: duration } = this.state; + this.props.handleProcessing( + duration, + amount, + 'Stripe card payment submission' + ); + this.props.postChargeStripeCard({ + token, + amount, + duration + }); + } + handleSelectAmount(donationAmount: number) { this.setState({ donationAmount }); } @@ -227,15 +245,15 @@ class DonateForm extends Component { const usd = this.getFormattedAmountLabel(donationAmount); const hours = this.convertToTimeContributed(donationAmount); - return ( -

- {donationDuration === 'onetime' - ? t('donate.your-donation', { usd: usd, hours: hours }) - : donationDuration === 'month' - ? t('donate.your-donation-2', { usd: usd, hours: hours }) - : t('donate.your-donation-3', { usd: usd, hours: hours })} -

- ); + let donationDescription = t('donate.your-donation-3', { usd, hours }); + + if (donationDuration === 'onetime') { + donationDescription = t('donate.your-donation', { usd, hours }); + } else if (donationDuration === 'month') { + donationDescription = t('donate.your-donation-2', { usd, hours }); + } + + return

{donationDescription}

; } resetDonation() { @@ -267,7 +285,7 @@ class DonateForm extends Component { renderButtonGroup() { const { donationAmount, donationDuration } = this.state; const { - donationFormState: { loading }, + donationFormState: { loading, processing }, handleProcessing, addDonation, defaultTheme, @@ -276,7 +294,6 @@ class DonateForm extends Component { isMinimalForm, isSignedIn } = this.props; - const paymentButtonsLoading = loading.stripe && loading.paypal; const priorityTheme = defaultTheme ? defaultTheme : theme; const isOneTime = donationDuration === 'onetime'; const walletlabel = `${t( @@ -290,8 +307,8 @@ class DonateForm extends Component { {this.getDonationButtonLabel()}: - {paymentButtonsLoading && this.paymentButtonsLoader()}
+ {loading.stripe && loading.paypal && this.paymentButtonsLoader()} { donationDuration={donationDuration} handlePaymentButtonLoad={this.handlePaymentButtonLoad} handleProcessing={handleProcessing} + isMinimalForm={isMinimalForm} isPaypalLoading={loading.paypal} isSignedIn={isSignedIn} onDonationStateChange={this.onDonationStateChange} theme={defaultTheme ? defaultTheme : theme} /> + {isMinimalForm && ( + <> +
{t('donate.or-card')}
+ + + )}
); diff --git a/client/src/components/Donation/Donation.css b/client/src/components/Donation/Donation.css index 508196b7e7..6a5dfd924c 100644 --- a/client/src/components/Donation/Donation.css +++ b/client/src/components/Donation/Donation.css @@ -15,6 +15,45 @@ display: flex; flex-direction: column; justify-content: space-between; + border-radius: 5px; + border: solid 1px var(--gray-45); + background: var(--gray-0); +} +.donation-elements div:nth-child(1) { + flex: 1; + border-radius: 5px 5px 0px 0px; + border: none; +} + +.donation-elements div:nth-child(2) { + flex: 1; + border-color: var(--gray-45); + border-radius: 0px 0px 5px 5px; + border-right: none; + border-left: none; + border-bottom: none; +} +.donation-elements.failed-submition { + border: solid 3px #eb1c26; +} + +@media (min-width: 380px) { + .donation-elements { + flex-direction: row; + } + .donation-elements div:nth-child(1) { + flex: 4 4 80%; + border-radius: 5px 0px 0px 5px; + border: none; + } + .donation-elements div:nth-child(2) { + flex: 1 1 100px; + border-radius: 0px 5px 5px 0px; + border: 1px solid var(--gray-45); + border-right: none; + border-top: none; + border-bottom: none; + } } .donation-completion, @@ -52,7 +91,7 @@ } .paypal-buttons-container { - min-height: 142px; + min-height: auto; } .donate-input-element { @@ -366,7 +405,7 @@ li.disabled > a { align-self: center; } -button#confirm-donation-btn { +button.confirm-donation-btn { display: flex; flex-direction: row; justify-content: center; @@ -376,10 +415,15 @@ button#confirm-donation-btn { border-color: var(--yellow-light); color: black; font-weight: bold; + width: 100%; + min-height: 42px; + border: none; + display: inline-flex; + align-items: center; } -button#confirm-donation-btn:active, -button#confirm-donation-btn:active:focus, -button#confirm-donation-btn:hover { +button.confirm-donation-btn:active, +button.confirm-donation-btn:active:focus, +button.confirm-donation-btn:hover { color: black; background-color: #f2ba38; border-color: #f2ba38; @@ -394,6 +438,8 @@ button#confirm-donation-btn:hover { flex-direction: column; justify-content: center; align-items: center; + max-width: 350px; + margin: 0 auto; } .donate-btn-group > * { @@ -404,13 +450,45 @@ button#confirm-donation-btn:hover { margin-bottom: 12px; } -@media (min-width: 500px) { - .donate-btn-group > * { - width: 49%; - } +.form-status { + min-height: 35px; + padding: 5px 0px; +} + +.form-status p { + font-family: 'Lato', sans-serif; + font-size: 16px; + color: #eb1c26; + text-align: left; + margin: 0px; + font-weight: bold; } .donate-page-wrapper .alert.alert-info a:hover { color: #327290; background-color: #acdef3; } + +.separator { + display: flex; + align-items: center; + text-align: center; + font-size: 0.9em; + margin: 20px 0; + color: var(--quaternary-color); +} + +.separator::before, +.separator::after { + content: ''; + flex: 1; + border-bottom: 1px solid var(--quaternary-background); +} + +.separator:not(:empty)::before { + margin-right: 0.25em; +} + +.separator:not(:empty)::after { + margin-left: 0.25em; +} diff --git a/client/src/components/Donation/DonationModal.tsx b/client/src/components/Donation/DonationModal.tsx index 3d913bca1a..4758d44331 100644 --- a/client/src/components/Donation/DonationModal.tsx +++ b/client/src/components/Donation/DonationModal.tsx @@ -155,7 +155,7 @@ function DonateModal({ {recentlyClaimedBlock ? blockDonationText : progressDonationText} - + { // Lint says that paypal does not exist on window - state = { isSdkLoaded: window.paypal ? true : false, isSubscription: true }; + state = { + isSdkLoaded: window.paypal ? true : false, + isSubscription: true + }; static displayName = 'PayPalButtonScriptLoader'; @@ -84,11 +88,11 @@ export class PayPalButtonScriptLoader extends Component< } componentDidMount(): void { - if (!window.paypal) { - this.loadScript(this.props.isSubscription, false); - } else if (this.props.isPaypalLoading) { - this.props.onLoad(); - } + this.loadScript(this.props.isSubscription, true); + } + + componentWillUnmount(): void { + scriptRemover('paypal-sdk'); } componentDidUpdate(prevProps: { @@ -98,12 +102,15 @@ export class PayPalButtonScriptLoader extends Component< height: number; tagline: boolean; }; + isMinimalForm: boolean | undefined; }): void { + // We need to load a new script if any of the following changes. if ( prevProps.isSubscription !== this.state.isSubscription || prevProps.style.color !== this.props.style.color || prevProps.style.tagline !== this.props.style.tagline || - prevProps.style.height !== this.props.style.height + prevProps.style.height !== this.props.style.height || + prevProps.isMinimalForm !== this.props.isMinimalForm ) { // eslint-disable-next-line react/no-did-update-set-state this.setState({ isSdkLoaded: false }); @@ -113,7 +120,8 @@ export class PayPalButtonScriptLoader extends Component< loadScript(subscription: boolean, deleteScript: boolean | undefined): void { if (deleteScript) scriptRemover('paypal-sdk'); - let queries = `?client-id=${this.props.clientId}&disable-funding=credit,bancontact,blik,eps,giropay,ideal,mybank,p24,sepa,sofort,venmo`; + const allowCardPayment = this.props.isMinimalForm ? 'card,' : ''; + let queries = `?client-id=${this.props.clientId}&disable-funding=${allowCardPayment}credit,bancontact,blik,eps,giropay,ideal,mybank,p24,sepa,sofort,venmo`; if (subscription) queries += '&vault=true&intent=subscription'; scriptLoader( diff --git a/client/src/components/Donation/PaypalButton.tsx b/client/src/components/Donation/PaypalButton.tsx index 30c4f5e2f9..3ede8777d7 100644 --- a/client/src/components/Donation/PaypalButton.tsx +++ b/client/src/components/Donation/PaypalButton.tsx @@ -44,6 +44,7 @@ type PaypalButtonProps = { theme: string; isSubscription?: boolean; handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void; + isMinimalForm: boolean | undefined; }; type PaypalButtonState = { @@ -128,7 +129,7 @@ export class PaypalButton extends Component< render(): JSX.Element | null { const { duration, planId, amount } = this.state; - const { t, theme, isPaypalLoading } = this.props; + const { t, theme, isPaypalLoading, isMinimalForm } = this.props; const isSubscription = duration !== 'onetime'; const buttonColor = theme === 'night' ? 'white' : 'gold'; if (!paypalClientId) { @@ -175,6 +176,7 @@ export class PaypalButton extends Component< plan_id: planId }); }} + isMinimalForm={isMinimalForm} isPaypalLoading={isPaypalLoading} isSubscription={isSubscription} onApprove={(data: AddDonationData) => { diff --git a/client/src/components/Donation/paypal-button.test.tsx b/client/src/components/Donation/paypal-button.test.tsx index 43c21b0727..c1bd045461 100644 --- a/client/src/components/Donation/paypal-button.test.tsx +++ b/client/src/components/Donation/paypal-button.test.tsx @@ -12,7 +12,8 @@ const commonProps = { isPaypalLoading: true, t: jest.fn(), theme: 'night', - handlePaymentButtonLoad: jest.fn() + handlePaymentButtonLoad: jest.fn(), + isMinimalForm: true }; const donationData = { diff --git a/client/src/components/Donation/stripe-card-form.tsx b/client/src/components/Donation/stripe-card-form.tsx new file mode 100644 index 0000000000..a7da3f3f49 --- /dev/null +++ b/client/src/components/Donation/stripe-card-form.tsx @@ -0,0 +1,160 @@ +/* eslint-disable no-undefined */ +import { Button, Form } from '@freecodecamp/react-bootstrap'; +import { + CardNumberElement, + CardExpiryElement, + useStripe, + useElements, + Elements +} from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import type { + Token, + StripeCardNumberElementChangeEvent, + StripeCardExpiryElementChangeEvent +} from '@stripe/stripe-js'; +import React, { useState } from 'react'; + +import envData from '../../../../config/env.json'; +import { AddDonationData } from './PaypalButton'; + +const { stripePublicKey }: { stripePublicKey: string | null } = envData; +interface FormPropTypes { + onDonationStateChange: (donationState: AddDonationData) => void; + postStripeCardDonation: (token: Token) => void; + t: (label: string) => string; + theme: string; + processing: boolean; +} + +interface Element { + elementType: 'cardNumber' | 'cardExpiry'; + complete: boolean; + error?: null | { type: 'validation_error'; code: string; message: string }; +} + +type PaymentInfoValidation = Element[]; + +const StripeCardForm = ({ + theme, + t, + onDonationStateChange, + postStripeCardDonation, + processing +}: FormPropTypes): JSX.Element => { + const [isSubmissionValid, setSubmissionValidity] = useState(true); + const [isTokenizing, setTokenizing] = useState(false); + const [paymentInfoValidation, setPaymentValidity] = + useState([ + { + elementType: 'cardNumber', + complete: false, + error: null + }, + { + elementType: 'cardExpiry', + complete: false, + error: null + } + ]); + const isPaymentInfoValid = paymentInfoValidation.every( + ({ complete, error }) => complete && !error + ); + const isSubmitting = isTokenizing || processing; + const stripe = useStripe(); + const elements = useElements(); + + function handleInputChange( + event: + | StripeCardNumberElementChangeEvent + | StripeCardExpiryElementChangeEvent + ) { + const { elementType, error, complete } = event; + setPaymentValidity( + paymentInfoValidation.map(element => { + if (element.elementType === elementType) + return { elementType, error, complete }; + return element; + }) + ); + } + + const options = { + style: { + base: { + fontSize: '18px', + color: `${theme === 'night' ? '#fff' : '#0a0a23'}` + } + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!isPaymentInfoValid) return setSubmissionValidity(false); + else setSubmissionValidity(true); + + if (!isSubmitting && stripe && elements) { + const cardElement = elements.getElement(CardNumberElement); + if (cardElement) { + setTokenizing(true); + const { error, token } = await stripe.createToken(cardElement); + if (error) { + onDonationStateChange({ + redirecting: false, + processing: false, + success: false, + error: t('donate.went-wrong') + }); + } else if (token) postStripeCardDonation(token); + } + } + return setTokenizing(false); + }; + + return ( +
+
+ + +
+
+ {!isSubmissionValid &&

{t('donate.valid-card')}

} +
+ +
+ ); +}; + +const CardFormWrapper = (props: FormPropTypes): JSX.Element | null => { + if (!stripePublicKey) { + return null; + } else { + return ( + + + + ); + } +}; + +export default CardFormWrapper; diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index 773df7e39f..c1b89eb70c 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -28,7 +28,7 @@ export const actionTypes = createTypes( ...createAsyncTypes('acceptTerms'), ...createAsyncTypes('showCert'), ...createAsyncTypes('reportUser'), - ...createAsyncTypes('postChargeStripe') + ...createAsyncTypes('postChargeStripeCard') ], ns ); diff --git a/client/src/redux/donation-saga.js b/client/src/redux/donation-saga.js index 1b66c21f11..ac89dd211b 100644 --- a/client/src/redux/donation-saga.js +++ b/client/src/redux/donation-saga.js @@ -7,7 +7,11 @@ import { call, take } from 'redux-saga/effects'; -import { addDonation, postChargeStripe } from '../utils/ajax'; +import { + addDonation, + postChargeStripe, + postChargeStripeCard +} from '../utils/ajax'; import { actionTypes as appTypes } from './action-types'; import { @@ -19,10 +23,12 @@ import { addDonationComplete, addDonationError, postChargeStripeComplete, - postChargeStripeError + postChargeStripeError, + postChargeStripeCardComplete, + postChargeStripeCardError } from './'; -const defaultDonationError = `Something is not right. Please contact donors@freecodecamp.org`; +const defaultDonationErrorMessage = `Something is not right. Please contact donors@freecodecamp.org`; function* showDonateModalSaga() { let shouldRequestDonation = yield select(shouldRequestDonationSelector); @@ -48,7 +54,7 @@ function* addDonationSaga({ payload }) { error.response && error.response.data ? error.response.data : { - message: defaultDonationError + message: defaultDonationErrorMessage }; yield put(addDonationError(data.message)); } @@ -62,15 +68,27 @@ function* postChargeStripeSaga({ payload }) { const err = error.response && error.response.data ? error.response.data.error - : defaultDonationError; + : defaultDonationErrorMessage; yield put(postChargeStripeError(err)); } } +function* postChargeStripeCardSaga({ payload }) { + try { + const { error } = yield call(postChargeStripeCard, payload); + if (error) throw error; + yield put(postChargeStripeCardComplete()); + } catch (error) { + const errorMessage = error.message || defaultDonationErrorMessage; + yield put(postChargeStripeCardError(errorMessage)); + } +} + export function createDonationSaga(types) { return [ takeEvery(types.tryToShowDonationModal, showDonateModalSaga), takeEvery(types.addDonation, addDonationSaga), - takeLeading(types.postChargeStripe, postChargeStripeSaga) + takeLeading(types.postChargeStripe, postChargeStripeSaga), + takeLeading(types.postChargeStripeCard, postChargeStripeCardSaga) ]; } diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 6664db715d..332ff91014 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -136,6 +136,15 @@ export const postChargeStripeComplete = createAction( 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 fetchProfileForUser = createAction( actionTypes.fetchProfileForUser @@ -450,6 +459,29 @@ export const reducer = handleActions( ...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/ajax.ts b/client/src/utils/ajax.ts index 26c91b34da..2f4d59e0a0 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -186,6 +186,10 @@ export function addDonation(body: Donation): Promise { export function postChargeStripe(body: Donation): Promise { return post('/donate/charge-stripe', body); } + +export function postChargeStripeCard(body: Donation): Promise { + return post('/donate/charge-stripe-card', body); +} interface Report { username: string; reportDescription: string;