Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): add charge-stripe endpoint #54545

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/src/app.ts
Expand Up @@ -37,7 +37,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 { settingRoutes } from './routes/settings';
import { statusRoute } from './routes/status';
import { userGetRoutes, userRoutes } from './routes/user';
Expand Down Expand Up @@ -205,6 +205,7 @@ export const build = async (
void fastify.register(challengeRoutes);
void fastify.register(settingRoutes);
void fastify.register(donateRoutes);
void fastify.register(chargeStripeRoute);
void fastify.register(userRoutes);
void fastify.register(protectedCertificateRoutes);
void fastify.register(unprotectedCertificateRoutes);
Expand Down
32 changes: 32 additions & 0 deletions api/src/plugins/code-flow-auth.ts
Expand Up @@ -23,6 +23,10 @@ declare module 'fastify' {
interface FastifyInstance {
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
}

interface FastifyInstance {
addUserIfAuthorized: (req: FastifyRequest, reply: FastifyReply) => void;
}
}

const codeFlowAuth: FastifyPluginCallback = (fastify, _options, done) => {
Expand Down Expand Up @@ -80,6 +84,34 @@ const codeFlowAuth: FastifyPluginCallback = (fastify, _options, done) => {
}
);

fastify.decorate(
'addUserIfAuthorized',
async function (req: FastifyRequest, reply: FastifyReply) {
const tokenCookie = req.cookies.jwt_access_token;
if (tokenCookie) {
const unsignedToken = req.unsignCookie(tokenCookie);
if (unsignedToken.valid) {
const jwtAccessToken = unsignedToken.value;
try {
jwt.verify(jwtAccessToken!, JWT_SECRET);
const {
accessToken: { created, ttl, userId }
} = jwt.decode(jwtAccessToken!) as { accessToken: AccessToken };
const valid = isBefore(Date.now(), Date.parse(created) + ttl);
if (valid) {
const user = await fastify.prisma.user.findUnique({
where: { id: userId }
});
if (user) req.user = user;
}
} catch {
return send401(reply, TOKEN_INVALID);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I need to return an error or not here since we are just checking if user is authorized and pass the user object.

}
}
}
}
);

done();
};

Expand Down
104 changes: 96 additions & 8 deletions api/src/routes/donate.test.ts
Expand Up @@ -14,6 +14,21 @@ const chargeStripeCardReqBody = {
duration: 'month'
};
const mockSubCreate = jest.fn();
const mockAttachPaymentMethod = jest.fn(() =>
Promise.resolve({
id: 'pm_1MqLiJLkdIwHu7ixUEgbFdYF',
object: 'payment_method'
})
);
const mockCustomerCreate = jest.fn(() =>
Promise.resolve({
id: 'cust_111',
name: 'Jest_User',
currency: 'sgd',
description: 'Jest User Account created'
})
);
const mockCustomerUpdate = jest.fn();
const generateMockSubCreate = (status: string) => () =>
Promise.resolve({
id: 'cust_111',
Expand All @@ -31,14 +46,11 @@ 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'
})
)
create: mockCustomerCreate,
update: mockCustomerUpdate
},
paymentMethods: {
attach: mockAttachPaymentMethod
},
subscriptions: {
create: mockSubCreate
Expand Down Expand Up @@ -80,6 +92,14 @@ const userWithProgress: Prisma.userCreateInput = {
]
};

const chargeStripeReqBody = {
email: 'lololemon@gmail.com',
name: 'Lolo Lemon',
token: { id: 'tok_123' },
amount: 500,
duration: 'month'
};

describe('Donate', () => {
setupServer();

Expand Down Expand Up @@ -214,6 +234,65 @@ describe('Donate', () => {
expect(failResponse.status).toBe(400);
});
});

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
);

expect(mockCustomerCreate).toHaveBeenCalledWith({
email: 'lololemon@gmail.com',
name: 'Lolo Lemon'
});

expect(mockAttachPaymentMethod).toHaveBeenCalledWith('tok_123', {
customer: 'cust_111'
});
expect(mockCustomerUpdate).toHaveBeenCalledWith('cust_111', {
invoice_settings: {
default_payment_method: 'pm_1MqLiJLkdIwHu7ixUEgbFdYF'
}
});

expect(response.status).toBe(200);
});

it('should return 500 when email format is wrong', async () => {
const response = await superPost('/donate/charge-stripe').send({
...chargeStripeReqBody,
email: '12raqdcev'
});
expect(response.body).toEqual({
error: 'The donation form had invalid values for this submission.'
});
expect(response.status).toBe(500);
});
it('should return 500 if amount is incorrect', async () => {
const response = await superPost('/donate/charge-stripe').send({
...chargeStripeReqBody,
amount: '350'
});
expect(response.body).toEqual({
error: 'The donation form had invalid values for this submission.'
});
expect(response.status).toBe(500);
});

it('should return 500 if Stripe encounters an error', async () => {
mockSubCreate.mockImplementationOnce(defaultError);
const response = await superPost('/donate/charge-stripe').send(
chargeStripeReqBody
);
expect(response.body).toEqual({
error: 'Donation failed due to a server error.'
});
expect(response.status).toBe(500);
});
});
});

describe('Unauthenticated User', () => {
Expand All @@ -236,5 +315,14 @@ describe('Donate', () => {
expect(response.statusCode).toBe(401);
});
});

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);
});
});
});
141 changes: 120 additions & 21 deletions api/src/routes/donate.ts
@@ -1,12 +1,10 @@
import {
Type,
type FastifyPluginCallbackTypebox
} from '@fastify/type-provider-typebox';
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import Stripe from 'stripe';

import isEmail from 'validator/lib/isEmail';
import { donationSubscriptionConfig } from '../../../shared/config/donation-settings';
import { schemas } from '../schemas';
import { STRIPE_SECRET_KEY } from '../utils/env';
import { findOrCreateUser } from './helpers/auth-helpers';

/**
* Plugin for the donation endpoints.
Expand Down Expand Up @@ -35,22 +33,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
fastify.post(
'/donate/add-donation',
{
schema: {
body: Type.Object({}),
response: {
200: Type.Object({
isDonating: Type.Boolean()
}),
400: Type.Object({
message: Type.Literal('User is already donating.'),
type: Type.Literal('info')
}),
500: Type.Object({
message: Type.Literal('Something went wrong.'),
type: Type.Literal('danger')
})
}
}
schema: schemas.addDonation
},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a clean up.

async (req, reply) => {
try {
Expand Down Expand Up @@ -214,3 +197,119 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (

done();
};

/**
* Plugin for the 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: '2020-08-27',
typescript: true
});

// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.addUserIfAuthorized);
fastify.post(
'/donate/charge-stripe',
{
schema: schemas.chargeStripe
},
async (req, reply) => {
try {
const id = req.user?.id;
const { email, name, token, amount, duration } = req.body;

// verify the parameters
if (
!isEmail(email) ||
!donationSubscriptionConfig.plans[duration].includes(amount)
) {
void reply.code(500);
Comment on lines +234 to +237
Copy link
Member Author

@ahmaxed ahmaxed May 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improved the validation slightly to check existing for plan.

return {
error: 'The donation form had invalid values for this submission.'
} as const;
}

// TODO(Post-MVP) new users should not be created if user is not found
const user = id
? await fastify.prisma.user.findUniqueOrThrow({ where: { id } })
: await findOrCreateUser(fastify, email);

const { id: customerId } = await stripe.customers.create({
email,
name
});

// TODO(Post-MVP) stripe has moved to a paymentintent flow, the create call should be updated to reflect this
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should figure out if this might benefit from being done before creating the customer.

Suggested change
const { id: paymentMethodId } = await stripe.paymentMethods.create({
type: 'card',
card: { token: token.id }
});

const paymentMethod = await stripe.paymentMethods.attach(token.id, {
customer: customerId
});

Comment on lines +249 to +257
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current api, card has been passed directly when creating the customer. The current api works but the card definition has been removed from the ar types. As a result, I create the customer and then attach the payment method to it.
I will update current api to the new pattern as well.

await stripe.customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethod.id
}
});

const plan = `${donationSubscriptionConfig.duration[
duration
].toLowerCase()}-donation-${amount}`;

const { id: subscriptionId } = await stripe.subscriptions.create({
customer: customerId,
items: [{ plan }]
});

const donation = {
userId: user.id,
email,
amount,
duration,
provider: 'stripe',
subscriptionId,
customerId,
// 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({
type: 'success',
isDonating: true
});
Comment on lines +300 to +302
Copy link
Member Author

@ahmaxed ahmaxed May 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current api returns the whole subscription, but that is not used in the client.
I returned isDonating for consistency.

} catch (error) {
fastify.log.error(error);
void reply.code(500);
return {
error: 'Donation failed due to a server error.'
} as const;
}
}
);

done();
};
4 changes: 4 additions & 0 deletions api/src/schemas.ts
Expand Up @@ -10,6 +10,8 @@ import { projectCompleted } from './schemas/challenge/project-completed';
import { saveChallenge } from './schemas/challenge/save-challenge';
import { deprecatedEndpoints } from './schemas/deprecated';
import { chargeStripeCard } from './schemas/donate/charge-stripe-card';
import { addDonation } from './schemas/donate/add-donation';
import { chargeStripe } from './schemas/donate/charge-stripe';
import { updateMyAbout } from './schemas/settings/update-my-about';
import { updateMyClassroomMode } from './schemas/settings/update-my-classroom-mode';
import { updateMyEmail } from './schemas/settings/update-my-email';
Expand All @@ -36,6 +38,8 @@ export const schemas = {
certificateVerify,
certSlug,
chargeStripeCard,
addDonation,
chargeStripe,
coderoadChallengeCompleted,
deleteMyAccount,
deleteMsUsername,
Expand Down
18 changes: 18 additions & 0 deletions api/src/schemas/donate/add-donation.ts
@@ -0,0 +1,18 @@
import { Type } from '@fastify/type-provider-typebox';

export const addDonation = {
body: Type.Object({}),
response: {
200: Type.Object({
isDonating: Type.Boolean()
}),
400: Type.Object({
message: Type.Literal('User is already donating.'),
type: Type.Literal('info')
}),
500: Type.Object({
message: Type.Literal('Something went wrong.'),
type: Type.Literal('danger')
})
}
};