From a1c12847e40d2cfecedd34da6ddc60b6d4eb9ad0 Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Tue, 6 Aug 2024 02:52:03 +0300 Subject: [PATCH] feat(api): add update-stripe-card endpoint (#55548) Co-authored-by: Oliver Eyton-Williams --- api/src/routes/donate.test.ts | 70 ++++++++++++++++++-- api/src/routes/donate.ts | 31 ++++++++- api/src/schemas.ts | 1 + api/src/schemas/donate/update-stripe-card.ts | 12 ++++ client/src/utils/ajax.ts | 2 +- 5 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 api/src/schemas/donate/update-stripe-card.ts diff --git a/api/src/routes/donate.test.ts b/api/src/routes/donate.test.ts index 1d65e46d6e6..6efe35529f5 100644 --- a/api/src/routes/donate.test.ts +++ b/api/src/routes/donate.test.ts @@ -4,7 +4,8 @@ import { devLogin, setupServer, superRequest, - defaultUserEmail + defaultUserEmail, + defaultUserId } from '../../jest.utils'; 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 = { amount: 500, duration: 'month' @@ -93,6 +109,9 @@ const mockSubRetrieveObj = { status: 'active' }; const mockSubRetrieve = jest.fn(() => Promise.resolve(mockSubRetrieveObj)); +const mockCheckoutSessionCreate = jest.fn(() => + Promise.resolve({ id: 'checkout_session_id' }) +); const mockCustomerUpdate = jest.fn(); const generateMockSubCreate = (status: string) => () => Promise.resolve({ @@ -120,15 +139,22 @@ jest.mock('stripe', () => { subscriptions: { create: mockSubCreate, retrieve: mockSubRetrieve + }, + checkout: { + sessions: { + create: mockCheckoutSessionCreate + } } }; }); }); describe('Donate', () => { + let setCookies: string[]; setupServer(); describe('Authenticated User', () => { let superPost: ReturnType; + let superPut: ReturnType; const verifyUpdatedUserAndNewDonation = async (email: string) => { const user = await fastifyTestInstance.prisma.user.findFirst({ where: { email } @@ -162,8 +188,9 @@ describe('Donate', () => { }; beforeEach(async () => { - const setCookies = await devLogin(); + setCookies = await devLogin(); superPost = createSuperRequest({ method: 'POST', setCookies }); + superPut = createSuperRequest({ method: 'PUT', setCookies }); await fastifyTestInstance.prisma.user.updateMany({ where: { email: userWithProgress.email }, 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', () => { it('should return 200 and call stripe api properly', async () => { mockSubCreate.mockImplementationOnce( @@ -432,16 +492,16 @@ describe('Donate', () => { }); describe('Unauthenticated User', () => { - let setCookies: string[]; // Get the CSRF cookies from an unprotected route beforeAll(async () => { const res = await superRequest('/status/ping', { method: 'GET' }); 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/charge-stripe-card', method: 'POST' } + { path: '/donate/charge-stripe-card', method: 'POST' }, + { path: '/donate/update-stripe-card', method: 'PUT' } ]; endpoints.forEach(({ path, method }) => { diff --git a/api/src/routes/donate.ts b/api/src/routes/donate.ts index ad80c1fd545..a62b74e689a 100644 --- a/api/src/routes/donate.ts +++ b/api/src/routes/donate.ts @@ -8,7 +8,7 @@ import { allStripeProductIdsArray } from '../../../shared/config/donation-settings'; 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 { findOrCreateUser } from './helpers/auth-helpers'; @@ -30,6 +30,35 @@ export const donateRoutes: FastifyPluginCallbackTypebox = ( 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( '/donate/add-donation', { diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 2ecf1de3b81..c841ca03bf0 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -14,6 +14,7 @@ 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 { updateStripeCard } from './schemas/donate/update-stripe-card'; export { resubscribe } from './schemas/email-subscription/resubscribe'; export { unsubscribe } from './schemas/email-subscription/unsubscribe'; export { updateMyAbout } from './schemas/settings/update-my-about'; diff --git a/api/src/schemas/donate/update-stripe-card.ts b/api/src/schemas/donate/update-stripe-card.ts new file mode 100644 index 00000000000..ac3eabf1191 --- /dev/null +++ b/api/src/schemas/donate/update-stripe-card.ts @@ -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 + } +}; diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 07bb22ae5c5..b634b4462ef 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -243,7 +243,7 @@ export function addDonation(body: Donation): Promise> { } export function updateStripeCard() { - return put('/donate/update-stripe-card'); + return put('/donate/update-stripe-card', {}); } export function postChargeStripe(