From b2518cc347fd7d0656bfd20e3d03f3ac8a4ae073 Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Mon, 15 Jul 2024 16:23:51 +0300 Subject: [PATCH] feat(api): add charge-stripe and create-stripe-payment-intent endpoints (#54545) Co-authored-by: Oliver Eyton-Williams --- api/package.json | 2 +- api/src/app.ts | 3 +- api/src/routes/donate.test.ts | 330 +++++++++++++++--- api/src/routes/donate.ts | 170 ++++++++- api/src/schemas.ts | 2 + api/src/schemas/donate/charge-stripe.ts | 18 + .../donate/create-stripe-payment-intent.ts | 24 ++ api/src/utils/validate-donation.test.ts | 29 ++ api/src/utils/validate-donation.ts | 10 + pnpm-lock.yaml | 22 +- 10 files changed, 546 insertions(+), 64 deletions(-) create mode 100644 api/src/schemas/donate/charge-stripe.ts create mode 100644 api/src/schemas/donate/create-stripe-payment-intent.ts create mode 100644 api/src/utils/validate-donation.test.ts create mode 100644 api/src/utils/validate-donation.ts diff --git a/api/package.json b/api/package.json index 5851c428337..2b109c49d38 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/app.ts b/api/src/app.ts index dab88d3c1cd..aff6fc52059 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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); diff --git a/api/src/routes/donate.test.ts b/api/src/routes/donate.test.ts index 78b87824873..1d65e46d6e6 100644 --- a/api/src/routes/donate.test.ts +++ b/api/src/routes/donate.test.ts @@ -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; + 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); + }); }); }); diff --git a/api/src/routes/donate.ts b/api/src/routes/donate.ts index 72c85c79066..ad80c1fd545 100644 --- a/api/src/routes/donate.ts +++ b/api/src/routes/donate.ts @@ -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(); +}; diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 7e78a5e9e2c..2ecf1de3b81 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -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'; diff --git a/api/src/schemas/donate/charge-stripe.ts b/api/src/schemas/donate/charge-stripe.ts new file mode 100644 index 00000000000..b1ebe96a789 --- /dev/null +++ b/api/src/schemas/donate/charge-stripe.ts @@ -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.') + }) + } +}; diff --git a/api/src/schemas/donate/create-stripe-payment-intent.ts b/api/src/schemas/donate/create-stripe-payment-intent.ts new file mode 100644 index 00000000000..f28827443e5 --- /dev/null +++ b/api/src/schemas/donate/create-stripe-payment-intent.ts @@ -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.') + }) + } +}; diff --git a/api/src/utils/validate-donation.test.ts b/api/src/utils/validate-donation.test.ts new file mode 100644 index 00000000000..16a318d9e04 --- /dev/null +++ b/api/src/utils/validate-donation.test.ts @@ -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); + }); +}); diff --git a/api/src/utils/validate-donation.ts b/api/src/utils/validate-donation.ts new file mode 100644 index 00000000000..d51daaecc0a --- /dev/null +++ b/api/src/utils/validate-donation.ts @@ -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 +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6a3e66f2a9..cbe0d2df3ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: