mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-09 19:00:53 -04:00
feat(api): add charge-stripe and create-stripe-payment-intent endpoints (#54545)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -33,7 +33,7 @@
|
||||
"pino-pretty": "10.2.3",
|
||||
"query-string": "7.1.3",
|
||||
"rate-limit-mongo": "^2.3.2",
|
||||
"stripe": "8.222.0",
|
||||
"stripe": "16.0.0",
|
||||
"validator": "13.11.0"
|
||||
},
|
||||
"description": "The freeCodeCamp.org open-source codebase and curriculum",
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import { challengeRoutes } from './routes/challenge';
|
||||
import { deprecatedEndpoints } from './routes/deprecated-endpoints';
|
||||
import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe';
|
||||
import { donateRoutes } from './routes/donate';
|
||||
import { donateRoutes, chargeStripeRoute } from './routes/donate';
|
||||
import { emailSubscribtionRoutes } from './routes/email-subscription';
|
||||
import { settingRoutes, settingRedirectRoutes } from './routes/settings';
|
||||
import { statusRoute } from './routes/status';
|
||||
@@ -223,6 +223,7 @@ export const build = async (
|
||||
if (FCC_ENABLE_DEV_LOGIN_MODE) {
|
||||
void fastify.register(devAuthRoutes);
|
||||
}
|
||||
void fastify.register(chargeStripeRoute);
|
||||
void fastify.register(emailSubscribtionRoutes);
|
||||
void fastify.register(userPublicGetRoutes);
|
||||
void fastify.register(unprotectedCertificateRoutes);
|
||||
|
||||
@@ -8,48 +8,11 @@ import {
|
||||
} from '../../jest.utils';
|
||||
import { createUserInput } from '../utils/create-user';
|
||||
|
||||
const chargeStripeCardReqBody = {
|
||||
paymentMethodId: 'UID',
|
||||
amount: 500,
|
||||
duration: 'month'
|
||||
};
|
||||
const mockSubCreate = jest.fn();
|
||||
const generateMockSubCreate = (status: string) => () =>
|
||||
Promise.resolve({
|
||||
id: 'cust_111',
|
||||
latest_invoice: {
|
||||
payment_intent: {
|
||||
client_secret: 'superSecret',
|
||||
status
|
||||
}
|
||||
}
|
||||
});
|
||||
const defaultError = () =>
|
||||
Promise.reject(new Error('Stripe encountered an error'));
|
||||
|
||||
jest.mock('stripe', () => {
|
||||
return jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
customers: {
|
||||
create: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
id: 'cust_111',
|
||||
name: 'Jest_User',
|
||||
currency: 'sgd',
|
||||
description: 'Jest User Account created'
|
||||
})
|
||||
)
|
||||
},
|
||||
subscriptions: {
|
||||
create: mockSubCreate
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const testEWalletEmail = 'baz@bar.com';
|
||||
const testSubscriptionId = 'sub_test_id';
|
||||
const testCustomerId = 'cust_test_id';
|
||||
const userWithoutProgress: Prisma.userCreateInput =
|
||||
createUserInput(defaultUserEmail);
|
||||
|
||||
const userWithProgress: Prisma.userCreateInput = {
|
||||
...createUserInput(defaultUserEmail),
|
||||
completedChallenges: [
|
||||
@@ -79,12 +42,124 @@ const userWithProgress: Prisma.userCreateInput = {
|
||||
}
|
||||
]
|
||||
};
|
||||
const sharedDonationReqBody = {
|
||||
amount: 500,
|
||||
duration: 'month'
|
||||
};
|
||||
const chargeStripeReqBody = {
|
||||
email: testEWalletEmail,
|
||||
subscriptionId: 'sub_test_id',
|
||||
...sharedDonationReqBody
|
||||
};
|
||||
const chargeStripeCardReqBody = {
|
||||
paymentMethodId: 'UID',
|
||||
...sharedDonationReqBody
|
||||
};
|
||||
const createStripePaymentIntentReqBody = {
|
||||
email: testEWalletEmail,
|
||||
name: 'Baz Bar',
|
||||
token: { id: 'tok_123' },
|
||||
...sharedDonationReqBody
|
||||
};
|
||||
const mockSubCreate = jest.fn();
|
||||
const mockAttachPaymentMethod = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
id: 'pm_1MqLiJLkdIwHu7ixUEgbFdYF',
|
||||
object: 'payment_method'
|
||||
})
|
||||
);
|
||||
const mockCustomerCreate = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
id: testCustomerId,
|
||||
name: 'Jest_User',
|
||||
currency: 'sgd',
|
||||
description: 'Jest User Account created'
|
||||
})
|
||||
);
|
||||
const mockSubRetrieveObj = {
|
||||
id: testSubscriptionId,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
plan: {
|
||||
product: 'prod_GD1GGbJsqQaupl'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// 1 Jan 2040
|
||||
current_period_start: Math.floor(Date.now() / 1000),
|
||||
customer: testCustomerId,
|
||||
status: 'active'
|
||||
};
|
||||
const mockSubRetrieve = jest.fn(() => Promise.resolve(mockSubRetrieveObj));
|
||||
const mockCustomerUpdate = jest.fn();
|
||||
const generateMockSubCreate = (status: string) => () =>
|
||||
Promise.resolve({
|
||||
id: testSubscriptionId,
|
||||
latest_invoice: {
|
||||
payment_intent: {
|
||||
client_secret: 'superSecret',
|
||||
status
|
||||
}
|
||||
}
|
||||
});
|
||||
const defaultError = () =>
|
||||
Promise.reject(new Error('Stripe encountered an error'));
|
||||
|
||||
jest.mock('stripe', () => {
|
||||
return jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
customers: {
|
||||
create: mockCustomerCreate,
|
||||
update: mockCustomerUpdate
|
||||
},
|
||||
paymentMethods: {
|
||||
attach: mockAttachPaymentMethod
|
||||
},
|
||||
subscriptions: {
|
||||
create: mockSubCreate,
|
||||
retrieve: mockSubRetrieve
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
describe('Donate', () => {
|
||||
setupServer();
|
||||
|
||||
describe('Authenticated User', () => {
|
||||
let superPost: ReturnType<typeof createSuperRequest>;
|
||||
const verifyUpdatedUserAndNewDonation = async (email: string) => {
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email }
|
||||
});
|
||||
const donations = await fastifyTestInstance.prisma.donation.findMany({
|
||||
where: { userId: user?.id }
|
||||
});
|
||||
const donation = donations[0];
|
||||
expect(donations.length).toBe(1);
|
||||
expect(donation?.amount).toBe(sharedDonationReqBody.amount);
|
||||
expect(donation?.duration).toBe(sharedDonationReqBody.duration);
|
||||
expect(typeof donation?.subscriptionId).toBe('string');
|
||||
expect(donation?.customerId).toBe(testCustomerId);
|
||||
expect(donation?.provider).toBe('stripe');
|
||||
};
|
||||
const verifyNoUpdatedUserAndNoNewDonation = async (email: string) => {
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email }
|
||||
});
|
||||
const donations = await fastifyTestInstance.prisma.donation.findMany({});
|
||||
expect(user?.isDonating).toBe(false);
|
||||
expect(donations.length).toBe(0);
|
||||
};
|
||||
const verifyNoNewUserAndNoNewDonation = async () => {
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: testEWalletEmail }
|
||||
});
|
||||
const donations = await fastifyTestInstance.prisma.donation.findMany({});
|
||||
expect(user).toBe(null);
|
||||
expect(donations.length).toBe(0);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const setCookies = await devLogin();
|
||||
@@ -93,6 +168,10 @@ describe('Donate', () => {
|
||||
where: { email: userWithProgress.email },
|
||||
data: userWithProgress
|
||||
});
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: testEWalletEmail }
|
||||
});
|
||||
await fastifyTestInstance.prisma.donation.deleteMany({});
|
||||
});
|
||||
|
||||
describe('POST /donate/charge-stripe-card', () => {
|
||||
@@ -103,6 +182,7 @@ describe('Donate', () => {
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
await verifyUpdatedUserAndNewDonation(userWithProgress.email);
|
||||
expect(response.body).toEqual({ isDonating: true, type: 'success' });
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
@@ -114,7 +194,7 @@ describe('Donate', () => {
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
|
||||
expect(response.body).toEqual({
|
||||
error: {
|
||||
type: 'UserActionRequired',
|
||||
@@ -132,7 +212,7 @@ describe('Donate', () => {
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
|
||||
expect(response.body).toEqual({
|
||||
error: {
|
||||
type: 'PaymentMethodRequired',
|
||||
@@ -149,11 +229,13 @@ describe('Donate', () => {
|
||||
const successResponse = await superPost(
|
||||
'/donate/charge-stripe-card'
|
||||
).send(chargeStripeCardReqBody);
|
||||
|
||||
expect(successResponse.status).toBe(200);
|
||||
const failResponse = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
//Verify that only the first call changed the DB
|
||||
await verifyUpdatedUserAndNewDonation(userWithProgress.email);
|
||||
expect(successResponse.status).toBe(200);
|
||||
expect(failResponse.body).toEqual({
|
||||
error: {
|
||||
type: 'AlreadyDonatingError',
|
||||
@@ -168,6 +250,7 @@ describe('Donate', () => {
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
@@ -182,6 +265,7 @@ describe('Donate', () => {
|
||||
const failResponse = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
|
||||
expect(failResponse.body).toEqual({
|
||||
error: {
|
||||
type: 'MethodRestrictionError',
|
||||
@@ -198,7 +282,10 @@ describe('Donate', () => {
|
||||
anything: true,
|
||||
itIs: 'ignored'
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: userWithProgress.email }
|
||||
});
|
||||
expect(user?.isDonating).toBe(true);
|
||||
expect(response.body).toEqual({
|
||||
isDonating: true
|
||||
});
|
||||
@@ -214,6 +301,134 @@ describe('Donate', () => {
|
||||
expect(failResponse.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /donate/create-stripe-payment-intent', () => {
|
||||
it('should return 200 and call stripe api properly', async () => {
|
||||
mockSubCreate.mockImplementationOnce(
|
||||
generateMockSubCreate('no-errors')
|
||||
);
|
||||
const response = await superPost(
|
||||
'/donate/create-stripe-payment-intent'
|
||||
).send(createStripePaymentIntentReqBody);
|
||||
expect(mockCustomerCreate).toHaveBeenCalledWith({
|
||||
email: testEWalletEmail,
|
||||
name: 'Baz Bar'
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 400 when email format is wrong', async () => {
|
||||
const response = await superPost(
|
||||
'/donate/create-stripe-payment-intent'
|
||||
).send({
|
||||
...createStripePaymentIntentReqBody,
|
||||
email: '12raqdcev'
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
error: 'The donation form had invalid values for this submission.'
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 if amount is incorrect', async () => {
|
||||
const response = await superPost(
|
||||
'/donate/create-stripe-payment-intent'
|
||||
).send({
|
||||
...createStripePaymentIntentReqBody,
|
||||
amount: '350'
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
error: 'The donation form had invalid values for this submission.'
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 500 if Stripe encounters an error', async () => {
|
||||
mockSubCreate.mockImplementationOnce(defaultError);
|
||||
const response = await superPost(
|
||||
'/donate/create-stripe-payment-intent'
|
||||
).send(createStripePaymentIntentReqBody);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /donate/charge-stripe', () => {
|
||||
it('should return 200 and call stripe api properly', async () => {
|
||||
mockSubCreate.mockImplementationOnce(
|
||||
generateMockSubCreate('no-errors')
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe').send(
|
||||
chargeStripeReqBody
|
||||
);
|
||||
await verifyUpdatedUserAndNewDonation(testEWalletEmail);
|
||||
expect(mockSubRetrieve).toHaveBeenCalledWith('sub_test_id');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 500 when if product id is wrong', async () => {
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
...mockSubRetrieveObj,
|
||||
items: {
|
||||
...mockSubRetrieveObj.items,
|
||||
data: [
|
||||
{
|
||||
...mockSubRetrieveObj.items.data[0],
|
||||
plan: {
|
||||
product: 'wrong_product_id'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe').send(
|
||||
chargeStripeReqBody
|
||||
);
|
||||
await verifyNoNewUserAndNoNewDonation();
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 500 if subsciption is not active', async () => {
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
...mockSubRetrieveObj,
|
||||
status: 'canceled'
|
||||
})
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe').send(
|
||||
chargeStripeReqBody
|
||||
);
|
||||
await verifyNoNewUserAndNoNewDonation();
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 500 if timestamp is old', async () => {
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
...mockSubRetrieveObj,
|
||||
current_period_start: Math.floor(Date.now() / 1000) - 500
|
||||
})
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe').send(
|
||||
chargeStripeReqBody
|
||||
);
|
||||
await verifyNoNewUserAndNoNewDonation();
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unauthenticated User', () => {
|
||||
@@ -223,10 +438,12 @@ describe('Donate', () => {
|
||||
const res = await superRequest('/status/ping', { method: 'GET' });
|
||||
setCookies = res.get('Set-Cookie');
|
||||
});
|
||||
|
||||
const endpoints: { path: string; method: 'POST' }[] = [
|
||||
{ path: '/donate/add-donation', method: 'POST' },
|
||||
{ path: '/donate/charge-stripe-card', method: 'POST' }
|
||||
];
|
||||
|
||||
endpoints.forEach(({ path, method }) => {
|
||||
test(`${method} ${path} returns 401 status code with error message`, async () => {
|
||||
const response = await superRequest(path, {
|
||||
@@ -236,5 +453,26 @@ describe('Donate', () => {
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test('POST /donate/create-stripe-payment-intent should return 200', async () => {
|
||||
mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors'));
|
||||
const response = await superRequest(
|
||||
'/donate/create-stripe-payment-intent',
|
||||
{
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}
|
||||
).send(createStripePaymentIntentReqBody);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('POST /donate/charge-stripe should return 200', async () => {
|
||||
mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors'));
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,17 @@ import {
|
||||
type FastifyPluginCallbackTypebox
|
||||
} from '@fastify/type-provider-typebox';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { donationSubscriptionConfig } from '../../../shared/config/donation-settings';
|
||||
import {
|
||||
donationSubscriptionConfig,
|
||||
allStripeProductIdsArray
|
||||
} from '../../../shared/config/donation-settings';
|
||||
import * as schemas from '../schemas';
|
||||
import { STRIPE_SECRET_KEY } from '../utils/env';
|
||||
import { inLastFiveMinutes } from '../utils/validate-donation';
|
||||
import { findOrCreateUser } from './helpers/auth-helpers';
|
||||
|
||||
/**
|
||||
* Plugin for the donation endpoints.
|
||||
* Plugin for the donation endpoints requiring auth.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
@@ -22,7 +26,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
) => {
|
||||
// Stripe plugin
|
||||
const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2020-08-27',
|
||||
apiVersion: '2024-06-20',
|
||||
typescript: true
|
||||
});
|
||||
|
||||
@@ -173,7 +177,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
duration,
|
||||
provider: 'stripe',
|
||||
subscriptionId,
|
||||
customerId: id,
|
||||
customerId: customerId,
|
||||
// TODO(Post-MVP) migrate to startDate: new Date()
|
||||
startDate: {
|
||||
date: new Date().toISOString(),
|
||||
@@ -208,3 +212,159 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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;
|
||||
return reply.send({
|
||||
subscriptionId: stripeSubscription.id,
|
||||
clientSecret
|
||||
});
|
||||
} else {
|
||||
throw new Error('Stripe payment intent client secret is missing');
|
||||
}
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
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 subscription =
|
||||
await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const isSubscriptionActive = subscription.status === 'active';
|
||||
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)
|
||||
throw new Error(
|
||||
`Stripe subscription information is invalid: ${subscriptionId}`
|
||||
);
|
||||
if (!isProductIdValid)
|
||||
throw new Error(`Product ID is invalid: ${subscriptionId}`);
|
||||
if (!isStartedRecently)
|
||||
throw new Error(`Subscription is not recent: ${subscriptionId}`);
|
||||
if (!isValidCustomer)
|
||||
throw new Error(`Customer ID is invalid: ${subscriptionId}`);
|
||||
else {
|
||||
// TODO(Post-MVP) new users should not be created if user is not found
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
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
|
||||
}
|
||||
});
|
||||
return reply.send({
|
||||
isDonating: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
fastify.Sentry.captureException(error);
|
||||
void reply.code(500);
|
||||
return {
|
||||
error: 'Donation failed due to a server error.'
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ export { projectCompleted } from './schemas/challenge/project-completed';
|
||||
export { saveChallenge } from './schemas/challenge/save-challenge';
|
||||
export { deprecatedEndpoints } from './schemas/deprecated';
|
||||
export { chargeStripeCard } from './schemas/donate/charge-stripe-card';
|
||||
export { chargeStripe } from './schemas/donate/charge-stripe';
|
||||
export { createStripePaymentIntent } from './schemas/donate/create-stripe-payment-intent';
|
||||
export { resubscribe } from './schemas/email-subscription/resubscribe';
|
||||
export { unsubscribe } from './schemas/email-subscription/unsubscribe';
|
||||
export { updateMyAbout } from './schemas/settings/update-my-about';
|
||||
|
||||
18
api/src/schemas/donate/charge-stripe.ts
Normal file
18
api/src/schemas/donate/charge-stripe.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const chargeStripe = {
|
||||
body: Type.Object({
|
||||
amount: Type.Number(),
|
||||
duration: Type.Literal('month'),
|
||||
email: Type.String({ format: 'email', maxLength: 1024 }),
|
||||
subscriptionId: Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
isDonating: Type.Boolean()
|
||||
}),
|
||||
default: Type.Object({
|
||||
error: Type.Literal('Donation failed due to a server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
24
api/src/schemas/donate/create-stripe-payment-intent.ts
Normal file
24
api/src/schemas/donate/create-stripe-payment-intent.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const createStripePaymentIntent = {
|
||||
body: Type.Object({
|
||||
amount: Type.Number(),
|
||||
duration: Type.Literal('month'),
|
||||
email: Type.String({ format: 'email', maxLength: 1024 }),
|
||||
name: Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
subscriptionId: Type.String(),
|
||||
clientSecret: Type.String()
|
||||
}),
|
||||
400: Type.Object({
|
||||
error: Type.Literal(
|
||||
'The donation form had invalid values for this submission.'
|
||||
)
|
||||
}),
|
||||
default: Type.Object({
|
||||
error: Type.Literal('Donation failed due to a server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
29
api/src/utils/validate-donation.test.ts
Normal file
29
api/src/utils/validate-donation.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { inLastFiveMinutes } from './validate-donation';
|
||||
|
||||
describe('inLastFiveMinutes', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return true if the timestamp is within the last five minutes', () => {
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
const recentTimestamp = currentTimestamp - 100;
|
||||
expect(inLastFiveMinutes(recentTimestamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the timestamp is more than five minutes ago', () => {
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
const oldTimestamp = currentTimestamp - 400;
|
||||
expect(inLastFiveMinutes(oldTimestamp)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if the timestamp is exactly five minutes ago', () => {
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
const exactTimestamp = currentTimestamp - 300;
|
||||
expect(inLastFiveMinutes(exactTimestamp)).toBe(true);
|
||||
});
|
||||
});
|
||||
10
api/src/utils/validate-donation.ts
Normal file
10
api/src/utils/validate-donation.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Checks if a timestamp was created within five minutes.
|
||||
* @param unixTimestamp - A unix timestamp .
|
||||
* @returns - The generated email template.
|
||||
*/
|
||||
export const inLastFiveMinutes = (unixTimestamp: number) => {
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
const timeDifference = currentTimestamp - unixTimestamp;
|
||||
return timeDifference <= 300; // 300 seconds is 5 minutes
|
||||
};
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -238,8 +238,8 @@ importers:
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2
|
||||
stripe:
|
||||
specifier: 8.222.0
|
||||
version: 8.222.0
|
||||
specifier: 16.0.0
|
||||
version: 16.0.0
|
||||
validator:
|
||||
specifier: 13.11.0
|
||||
version: 13.11.0
|
||||
@@ -12669,14 +12669,14 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
stripe@16.0.0:
|
||||
resolution: {integrity: sha512-HNPTzXrSompUxeGqDpbNmPvH/UEzziIxGWvTDTZwIBLlL6CFqR+3YLpV+g4HXOsPKbQRguauptQwVWO7Y/yEGg==}
|
||||
engines: {node: '>=12.*'}
|
||||
|
||||
stripe@8.205.0:
|
||||
resolution: {integrity: sha512-hmYnc7je6j0n9GlkUpc8USsUquLzSxmWj78g9NKFokCtSybNy7y9fYS+VB5AuZUwmIkzhTczgf+TaSmI4kbk9A==}
|
||||
engines: {node: ^8.1 || >=10.*}
|
||||
|
||||
stripe@8.222.0:
|
||||
resolution: {integrity: sha512-hrA79fjmN2Eb6K3kxkDzU4ODeVGGjXQsuVaAPSUro6I9MM3X+BvIsVqdphm3BXWfimAGFvUqWtPtHy25mICY1w==}
|
||||
engines: {node: ^8.1 || >=10.*}
|
||||
|
||||
strnum@1.0.5:
|
||||
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
|
||||
|
||||
@@ -30088,16 +30088,16 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
stripe@16.0.0:
|
||||
dependencies:
|
||||
'@types/node': 20.12.8
|
||||
qs: 6.11.2
|
||||
|
||||
stripe@8.205.0:
|
||||
dependencies:
|
||||
'@types/node': 20.8.0
|
||||
qs: 6.11.2
|
||||
|
||||
stripe@8.222.0:
|
||||
dependencies:
|
||||
'@types/node': 20.8.2
|
||||
qs: 6.11.2
|
||||
|
||||
strnum@1.0.5: {}
|
||||
|
||||
strong-error-handler@3.5.0:
|
||||
|
||||
Reference in New Issue
Block a user