diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 3ed68350..89958800 100755 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -10,7 +10,7 @@ import items from './items'; import teams from './teams'; import auth from './auth'; import tickets from './tickets'; -import etupay from './stripe'; +import stripe from './stripe'; import discord from './discord'; import admin from './admin'; import commissions from './commissions'; @@ -58,7 +58,7 @@ router.use('/items', items); router.use('/tickets', tickets); // Etupay routes -router.use('/etupay', etupay); +router.use('/stripe', stripe); // Discord routes router.use('/discord', discord); diff --git a/src/controllers/stripe/index.ts b/src/controllers/stripe/index.ts index e8e8b5c6..3398bb89 100644 --- a/src/controllers/stripe/index.ts +++ b/src/controllers/stripe/index.ts @@ -1,8 +1,10 @@ import { Router } from 'express'; import paymentAcceptedWebhook from "./paymentAcceptedWebhook"; +import paymentExpiredWebhook from "./paymentExpiredWebhook"; const router = Router(); router.post('/accepted', paymentAcceptedWebhook); +router.post('/expired', paymentExpiredWebhook); export default router; diff --git a/src/controllers/stripe/paymentAcceptedWebhook.ts b/src/controllers/stripe/paymentAcceptedWebhook.ts index 52a0d266..c306017c 100644 --- a/src/controllers/stripe/paymentAcceptedWebhook.ts +++ b/src/controllers/stripe/paymentAcceptedWebhook.ts @@ -3,8 +3,8 @@ import stripe from 'stripe'; import Joi from 'joi'; import { fetchCartFromTransactionId, updateCart } from '../../operations/carts'; import { sendPaymentConfirmation } from '../../services/email'; -import { Error, EtupayError, TransactionState } from '../../types'; -import { badRequest, forbidden, notFound, success } from '../../utils/responses'; +import { Error, TransactionState } from '../../types'; +import { notFound, success } from '../../utils/responses'; import { validateBody } from '../../middlewares/validation'; // This route is a webhook called by stripe @@ -27,12 +27,6 @@ export default [ }).unknown(true), ), - // Create a small middleware to be able to handle payload errors. - // The eslint disabling is important because the error argument can only be gotten in the 4 arguments function - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (error: EtupayError, request: Request, response: Response, next: NextFunction) => - badRequest(response, Error.InvalidQueryParameters), - async (request: Request, response: Response, next: NextFunction) => { try { const session = request.body.data.object as stripe.Checkout.Session; @@ -45,14 +39,9 @@ export default [ return notFound(response, Error.CartNotFound); } - // If the transaction is already paid - if (cart.transactionState === TransactionState.paid) { - return forbidden(response, Error.CartAlreadyPaid); - } - - // If the transaction is already errored + // If the transaction is already errored (force-payed, directly changed in database, ...) if (cart.transactionState !== TransactionState.pending) { - return forbidden(response, Error.AlreadyErrored); + return success(response, { api: 'ok' }); } // Update the cart with the callback data diff --git a/src/controllers/stripe/paymentExpiredWebhook.ts b/src/controllers/stripe/paymentExpiredWebhook.ts index 25d37b7d..31127bad 100644 --- a/src/controllers/stripe/paymentExpiredWebhook.ts +++ b/src/controllers/stripe/paymentExpiredWebhook.ts @@ -2,14 +2,13 @@ import { NextFunction, Request, Response } from 'express'; import stripe from 'stripe'; import Joi from 'joi'; import { fetchCartFromTransactionId, updateCart } from '../../operations/carts'; -import { sendPaymentConfirmation } from '../../services/email'; import { Error, EtupayError, TransactionState } from '../../types'; import { badRequest, forbidden, notFound, success } from '../../utils/responses'; import { validateBody } from '../../middlewares/validation'; // This route is a webhook called by stripe // To test it, first create an account on stripe, and install the stripe cli (https://docs.stripe.com/stripe-cli). Then, you can run the command : -// stripe listen --forward-to localhost:3000/stripe/accepted --events checkout.session.completed +// stripe listen --forward-to localhost:3000/stripe/accepted --events checkout.session.expired // Small tip I discovered a bit later than I would have liked to : you can resend an event with // stripe events resend // You can find the eventId at https://dashboard.stripe.com/test/workbench/webhooks @@ -27,12 +26,6 @@ export default [ }).unknown(true), ), - // Create a small middleware to be able to handle payload errors. - // The eslint disabling is important because the error argument can only be gotten in the 4 arguments function - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (error: EtupayError, request: Request, response: Response, next: NextFunction) => - badRequest(response, Error.InvalidQueryParameters), - async (request: Request, response: Response, next: NextFunction) => { try { const session = request.body.data.object as stripe.Checkout.Session; @@ -45,12 +38,7 @@ export default [ return notFound(response, Error.CartNotFound); } - // If the transaction is already paid - if (cart.transactionState === TransactionState.paid) { - return forbidden(response, Error.CartAlreadyPaid); - } - - // If the transaction is already errored, simply return. + // If the transaction is already errored or paid (probably forced-pay, else that's strange), simply return. if (cart.transactionState !== TransactionState.pending) { return success(response, { api: 'ok' }); } diff --git a/tests/admin/items/syncWithStripe.test.ts b/tests/admin/items/syncWithStripe.test.ts index 3041d4d1..fbb0e055 100644 --- a/tests/admin/items/syncWithStripe.test.ts +++ b/tests/admin/items/syncWithStripe.test.ts @@ -29,7 +29,6 @@ describe('PATCH /admin/items/stripe-sync', () => { after(async () => { // Delete the user created - await database.orga.deleteMany(); await database.user.deleteMany(); }); diff --git a/tests/stripe/paymentAcceptedWebhook.ts b/tests/stripe/paymentAcceptedWebhook.ts new file mode 100644 index 00000000..223acdce --- /dev/null +++ b/tests/stripe/paymentAcceptedWebhook.ts @@ -0,0 +1,62 @@ +import request from 'supertest'; +import { TransactionState } from '@prisma/client'; +import { expect } from 'chai'; +import app from '../../src/app'; +import { sandbox } from '../setup'; +import * as cartOperations from '../../src/operations/carts'; +import database from '../../src/services/database'; +import { Error, User, Cart } from '../../src/types'; +import { createFakeUser, createFakeCart } from '../utils'; +import { updateCart } from '../../src/operations/carts'; + +describe('POST /stripe/accepted', () => { + let user: User; + let cart: Cart; + + before(async () => { + user = await createFakeUser(); + cart = await createFakeCart({ userId: user.id, items: [] }); + }); + + after(async () => { + await database.user.deleteMany(); + await database.cart.deleteMany(); + }); + + const generateCart = (transactionId: string) => ({ + object: 'event', + type: 'checkout.session.accepted', + data: { + object: { + object: 'checkout.session', + id: transactionId, + }, + }, + }); + + it('should fail with an internal server error', async () => { + sandbox.stub(cartOperations, 'fetchCartFromTransactionId').throws('Unexpected error'); + await request(app).post('/stripe/accepted').send(generateCart('plz throw')).expect(500, { error: Error.InternalServerError }); + }); + + it('should fail as the transaction id does not exist', () => + request(app).post('/stripe/accepted').send(generateCart('I AM A H4X0R')).expect(404, { error: Error.CartNotFound })); + + it('should change the transactionState of the cart from pending to paid', async () => { + await updateCart(cart.id, 'supersecret', TransactionState.pending); + await request(app).post('/stripe/accepted').send(generateCart('supersecret')).expect(200, { api: 'ok' }); + const databaseCart = await database.cart.findUnique({ where: { id: cart.id } }); + expect(databaseCart.transactionState).to.equal(TransactionState.paid); + }); + + describe('test for initial transactionState = `paid` | `expired` | `refunded`', () => { + for (const transactionState of [TransactionState.paid, TransactionState.expired, TransactionState.refunded]) { + it(`should not change the transactionState of the cart as it already equals ${transactionState}`, async () => { + await updateCart(cart.id, 'supersecret', transactionState); + await request(app).post('/stripe/expired').send(generateCart('supersecret')).expect(200, { api: 'ok' }); + const databaseCart = await database.cart.findUnique({ where: { id: cart.id } }); + expect(databaseCart.transactionState).to.equal(transactionState); + }); + } + }); +}); diff --git a/tests/stripe/paymentExpiredWebhook.test.ts b/tests/stripe/paymentExpiredWebhook.test.ts new file mode 100644 index 00000000..06742ca5 --- /dev/null +++ b/tests/stripe/paymentExpiredWebhook.test.ts @@ -0,0 +1,62 @@ +import request from 'supertest'; +import { TransactionState } from '@prisma/client'; +import { expect } from 'chai'; +import app from '../../src/app'; +import { sandbox } from '../setup'; +import * as cartOperations from '../../src/operations/carts'; +import database from '../../src/services/database'; +import { Error, User, Cart } from '../../src/types'; +import { createFakeUser, createFakeCart } from '../utils'; +import { updateCart } from '../../src/operations/carts'; + +describe('POST /stripe/expired', () => { + let user: User; + let cart: Cart; + + before(async () => { + user = await createFakeUser(); + cart = await createFakeCart({ userId: user.id, items: [] }); + }); + + after(async () => { + await database.cart.deleteMany(); + await database.user.deleteMany(); + }); + + const generateCart = (transactionId: string) => ({ + object: 'event', + type: 'checkout.session.expired', + data: { + object: { + object: 'checkout.session', + id: transactionId, + }, + }, + }); + + it('should fail with an internal server error', async () => { + sandbox.stub(cartOperations, 'fetchCartFromTransactionId').throws('Unexpected error'); + await request(app).post('/stripe/expired').send(generateCart('plz throw')).expect(500, { error: Error.InternalServerError }); + }); + + it('should fail as the transaction id does not exist', () => + request(app).post('/stripe/expired').send(generateCart('I AM A H4X0R')).expect(404, { error: Error.CartNotFound })); + + it('should change the transactionState of the cart from `pending` to `expired`', async () => { + await updateCart(cart.id, 'supersecret', TransactionState.pending); + await request(app).post('/stripe/expired').send(generateCart('supersecret')).expect(200, { api: 'ok' }); + const databaseCart = await database.cart.findUnique({ where: { id: cart.id } }); + expect(databaseCart.transactionState).to.equal(TransactionState.expired); + }); + + describe('test for initial transactionState = `paid` | `expired` | `refunded`', () => { + for (const transactionState of [TransactionState.paid, TransactionState.expired, TransactionState.refunded]) { + it(`should not change the transactionState of the cart as it already equals ${transactionState}`, async () => { + await updateCart(cart.id, 'supersecret', transactionState); + await request(app).post('/stripe/expired').send(generateCart('supersecret')).expect(200, { api: 'ok' }); + const databaseCart = await database.cart.findUnique({ where: { id: cart.id } }); + expect(databaseCart.transactionState).to.equal(transactionState); + }); + } + }); +});