feat(api): add update-stripe-card endpoint (#55548)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
pull/55764/head
Ahmad Abdolsaheb 2024-08-06 02:52:03 +03:00 committed by GitHub
parent 65dfc044e8
commit a1c12847e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 109 additions and 7 deletions

View File

@ -4,7 +4,8 @@ import {
devLogin, devLogin,
setupServer, setupServer,
superRequest, superRequest,
defaultUserEmail defaultUserEmail,
defaultUserId
} from '../../jest.utils'; } from '../../jest.utils';
import { createUserInput } from '../utils/create-user'; import { createUserInput } from '../utils/create-user';
@ -42,6 +43,21 @@ const userWithProgress: Prisma.userCreateInput = {
} }
] ]
}; };
const donationMock = {
endDate: null,
startDate: {
date: '2024-07-17T10:20:56.076Z',
when: '2024-07-17T10:20:56.076+00:00'
},
id: '66979a414748aa2f3ba36d41',
amount: 500,
customerId: 'cust_test_id',
duration: 'month',
email: 'foo@bar.com',
provider: 'stripe',
subscriptionId: 'sub_test_id',
userId: defaultUserId
};
const sharedDonationReqBody = { const sharedDonationReqBody = {
amount: 500, amount: 500,
duration: 'month' duration: 'month'
@ -93,6 +109,9 @@ const mockSubRetrieveObj = {
status: 'active' status: 'active'
}; };
const mockSubRetrieve = jest.fn(() => Promise.resolve(mockSubRetrieveObj)); const mockSubRetrieve = jest.fn(() => Promise.resolve(mockSubRetrieveObj));
const mockCheckoutSessionCreate = jest.fn(() =>
Promise.resolve({ id: 'checkout_session_id' })
);
const mockCustomerUpdate = jest.fn(); const mockCustomerUpdate = jest.fn();
const generateMockSubCreate = (status: string) => () => const generateMockSubCreate = (status: string) => () =>
Promise.resolve({ Promise.resolve({
@ -120,15 +139,22 @@ jest.mock('stripe', () => {
subscriptions: { subscriptions: {
create: mockSubCreate, create: mockSubCreate,
retrieve: mockSubRetrieve retrieve: mockSubRetrieve
},
checkout: {
sessions: {
create: mockCheckoutSessionCreate
}
} }
}; };
}); });
}); });
describe('Donate', () => { describe('Donate', () => {
let setCookies: string[];
setupServer(); setupServer();
describe('Authenticated User', () => { describe('Authenticated User', () => {
let superPost: ReturnType<typeof createSuperRequest>; let superPost: ReturnType<typeof createSuperRequest>;
let superPut: ReturnType<typeof createSuperRequest>;
const verifyUpdatedUserAndNewDonation = async (email: string) => { const verifyUpdatedUserAndNewDonation = async (email: string) => {
const user = await fastifyTestInstance.prisma.user.findFirst({ const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email } where: { email }
@ -162,8 +188,9 @@ describe('Donate', () => {
}; };
beforeEach(async () => { beforeEach(async () => {
const setCookies = await devLogin(); setCookies = await devLogin();
superPost = createSuperRequest({ method: 'POST', setCookies }); superPost = createSuperRequest({ method: 'POST', setCookies });
superPut = createSuperRequest({ method: 'PUT', setCookies });
await fastifyTestInstance.prisma.user.updateMany({ await fastifyTestInstance.prisma.user.updateMany({
where: { email: userWithProgress.email }, where: { email: userWithProgress.email },
data: userWithProgress data: userWithProgress
@ -302,6 +329,39 @@ describe('Donate', () => {
}); });
}); });
describe('PUT /donate/update-stripe-card', () => {
it('should return 200 and return session id', async () => {
await fastifyTestInstance.prisma.donation.create({
data: donationMock
});
const response = await superPut('/donate/update-stripe-card').send({});
expect(mockCheckoutSessionCreate).toHaveBeenCalledWith({
cancel_url: 'http://localhost:8000/update-stripe-card',
customer: 'cust_test_id',
mode: 'setup',
payment_method_types: ['card'],
setup_intent_data: {
metadata: {
customer_id: 'cust_test_id',
subscription_id: 'sub_test_id'
}
},
success_url:
'http://localhost:8000/update-stripe-card?session_id={CHECKOUT_SESSION_ID}'
});
expect(response.body).toEqual({ sessionId: 'checkout_session_id' });
expect(response.status).toBe(200);
});
it('should return 500 if there is no donation record', async () => {
const response = await superPut('/donate/update-stripe-card').send({});
expect(response.body).toEqual({
message: 'flash.generic-error',
type: 'danger'
});
expect(response.status).toBe(500);
});
});
describe('POST /donate/create-stripe-payment-intent', () => { describe('POST /donate/create-stripe-payment-intent', () => {
it('should return 200 and call stripe api properly', async () => { it('should return 200 and call stripe api properly', async () => {
mockSubCreate.mockImplementationOnce( mockSubCreate.mockImplementationOnce(
@ -432,16 +492,16 @@ describe('Donate', () => {
}); });
describe('Unauthenticated User', () => { describe('Unauthenticated User', () => {
let setCookies: string[];
// Get the CSRF cookies from an unprotected route // Get the CSRF cookies from an unprotected route
beforeAll(async () => { beforeAll(async () => {
const res = await superRequest('/status/ping', { method: 'GET' }); const res = await superRequest('/status/ping', { method: 'GET' });
setCookies = res.get('Set-Cookie'); setCookies = res.get('Set-Cookie');
}); });
const endpoints: { path: string; method: 'POST' }[] = [ const endpoints: { path: string; method: 'POST' | 'PUT' }[] = [
{ path: '/donate/add-donation', method: 'POST' }, { path: '/donate/add-donation', method: 'POST' },
{ path: '/donate/charge-stripe-card', method: 'POST' } { path: '/donate/charge-stripe-card', method: 'POST' },
{ path: '/donate/update-stripe-card', method: 'PUT' }
]; ];
endpoints.forEach(({ path, method }) => { endpoints.forEach(({ path, method }) => {

View File

@ -8,7 +8,7 @@ import {
allStripeProductIdsArray allStripeProductIdsArray
} from '../../../shared/config/donation-settings'; } from '../../../shared/config/donation-settings';
import * as schemas from '../schemas'; import * as schemas from '../schemas';
import { STRIPE_SECRET_KEY } from '../utils/env'; import { STRIPE_SECRET_KEY, HOME_LOCATION } from '../utils/env';
import { inLastFiveMinutes } from '../utils/validate-donation'; import { inLastFiveMinutes } from '../utils/validate-donation';
import { findOrCreateUser } from './helpers/auth-helpers'; import { findOrCreateUser } from './helpers/auth-helpers';
@ -30,6 +30,35 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
typescript: true typescript: true
}); });
fastify.put(
'/donate/update-stripe-card',
{
schema: schemas.updateStripeCard
},
async req => {
const donation = await fastify.prisma.donation.findFirst({
where: { userId: req.user?.id, provider: 'stripe' }
});
if (!donation)
throw Error(`Stripe donation record not found: ${req.user?.id}`);
const { customerId, subscriptionId } = donation;
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'setup',
customer: customerId,
setup_intent_data: {
metadata: {
customer_id: customerId,
subscription_id: subscriptionId
}
},
success_url: `${HOME_LOCATION}/update-stripe-card?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${HOME_LOCATION}/update-stripe-card`
});
return { sessionId: session.id } as const;
}
);
fastify.post( fastify.post(
'/donate/add-donation', '/donate/add-donation',
{ {

View File

@ -14,6 +14,7 @@ export { deprecatedEndpoints } from './schemas/deprecated';
export { chargeStripeCard } from './schemas/donate/charge-stripe-card'; export { chargeStripeCard } from './schemas/donate/charge-stripe-card';
export { chargeStripe } from './schemas/donate/charge-stripe'; export { chargeStripe } from './schemas/donate/charge-stripe';
export { createStripePaymentIntent } from './schemas/donate/create-stripe-payment-intent'; export { createStripePaymentIntent } from './schemas/donate/create-stripe-payment-intent';
export { updateStripeCard } from './schemas/donate/update-stripe-card';
export { resubscribe } from './schemas/email-subscription/resubscribe'; export { resubscribe } from './schemas/email-subscription/resubscribe';
export { unsubscribe } from './schemas/email-subscription/unsubscribe'; export { unsubscribe } from './schemas/email-subscription/unsubscribe';
export { updateMyAbout } from './schemas/settings/update-my-about'; export { updateMyAbout } from './schemas/settings/update-my-about';

View File

@ -0,0 +1,12 @@
import { Type } from '@fastify/type-provider-typebox';
import { genericError } from '../types';
export const updateStripeCard = {
body: Type.Object({}),
response: {
200: Type.Object({
sessionId: Type.String()
}),
default: genericError
}
};

View File

@ -243,7 +243,7 @@ export function addDonation(body: Donation): Promise<ResponseWithData<void>> {
} }
export function updateStripeCard() { export function updateStripeCard() {
return put('/donate/update-stripe-card'); return put('/donate/update-stripe-card', {});
} }
export function postChargeStripe( export function postChargeStripe(