mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
199 lines
6.1 KiB
TypeScript
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();
|
|
};
|