Files
freeCodeCamp/api/src/routes/public/donate.ts
T
2025-10-28 19:13:50 +05:30

199 lines
6.1 KiB
TypeScript

import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY } from '../../utils/env.js';
import {
donationSubscriptionConfig,
allStripeProductIdsArray
} from '../../../../shared/config/donation-settings.js';
import * as schemas from '../../schemas.js';
import { inLastFiveMinutes } from '../../utils/validate-donation.js';
import { findOrCreateUser } from '../helpers/auth-helpers.js';
/**
* Plugin for public donation endpoints.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
* @param done The callback to signal that the plugin is ready.
*/
export const chargeStripeRoute: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
// Stripe plugin
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
typescript: true
});
fastify.post(
'/donate/create-stripe-payment-intent',
{
schema: schemas.createStripePaymentIntent
},
async (req, reply) => {
const { email, name, amount, duration } = req.body;
const log = fastify.log.child({ req, email, amount, duration });
log.debug('Creating Stripe payment intent');
if (!donationSubscriptionConfig.plans[duration].includes(amount)) {
void reply.code(400);
return {
error: 'The donation form had invalid values for this submission.'
} as const;
}
try {
const stripeCustomer = await stripe.customers.create({
email,
name
});
const stripeSubscription = await stripe.subscriptions.create({
customer: stripeCustomer.id,
items: [
{
plan: `${donationSubscriptionConfig.duration[duration]}-donation-${amount}`
}
],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent']
});
if (
stripeSubscription.latest_invoice &&
typeof stripeSubscription.latest_invoice !== 'string' &&
stripeSubscription.latest_invoice.payment_intent &&
typeof stripeSubscription.latest_invoice.payment_intent !==
'string' &&
stripeSubscription.latest_invoice.payment_intent.client_secret !==
null
) {
const clientSecret =
stripeSubscription.latest_invoice.payment_intent.client_secret;
log.info('Successfully created payment intent');
return reply.send({
subscriptionId: stripeSubscription.id,
clientSecret
});
} else {
throw new Error('Stripe payment intent client secret is missing');
}
} catch (error) {
log.error(error, 'Failed to create payment intent');
fastify.Sentry.captureException(error);
void reply.code(500);
return reply.send({
error: 'Donation failed due to a server error.'
});
}
}
);
fastify.post(
'/donate/charge-stripe',
{
schema: schemas.chargeStripe
},
async (req, reply) => {
try {
const { email, amount, duration, subscriptionId } = req.body;
const log = fastify.log.child({
req,
email,
amount,
duration,
subscriptionId
});
log.debug('Processing Stripe charge');
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
const isSubscriptionActive = subscription.status === 'active';
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const productId = subscription.items.data[0]?.plan.product?.toString();
const isStartedRecently = inLastFiveMinutes(
subscription.current_period_start
);
const isProductIdValid =
productId && allStripeProductIdsArray.includes(productId);
const isValidCustomer = typeof subscription.customer === 'string';
if (!isSubscriptionActive) {
log.warn(
{ status: subscription.status },
'Invalid subscription status'
);
throw new Error(
`Stripe subscription information is invalid: ${subscriptionId}`
);
}
if (!isProductIdValid) {
log.warn({ productId }, 'Invalid product ID');
throw new Error(`Product ID is invalid: ${subscriptionId}`);
}
if (!isStartedRecently) {
log.warn(
{ startTime: subscription.current_period_start },
'Subscription not recent'
);
throw new Error(`Subscription is not recent: ${subscriptionId}`);
}
if (!isValidCustomer) {
log.warn(
{ customerId: subscription.customer },
'Invalid customer ID'
);
throw new Error(`Customer ID is invalid: ${subscriptionId}`);
}
const user = await findOrCreateUser(fastify, email);
log.debug({ userId: user.id }, 'Found or created user');
const donation = {
userId: user.id,
email,
amount,
duration,
provider: 'stripe',
subscriptionId,
customerId: subscription.customer as string,
// TODO(Post-MVP) migrate to startDate: new Date()
startDate: {
date: new Date().toISOString(),
when: new Date().toISOString().replace(/.$/, '+00:00')
}
};
await fastify.prisma.donation.create({
data: donation
});
await fastify.prisma.user.update({
where: { id: user.id },
data: {
isDonating: true
}
});
log.info('Successfully processed donation');
return reply.send({
isDonating: true
});
} catch (error) {
fastify.log.error(error, 'Failed to process Stripe charge');
fastify.Sentry.captureException(error);
void reply.code(500);
return {
error: 'Donation failed due to a server error.'
} as const;
}
}
);
done();
};