From be699fdd303328cd6a49ae9682beda403d22784a Mon Sep 17 00:00:00 2001 From: Roy Kakkenberg <38660000+Yoronex@users.noreply.github.com> Date: Tue, 7 Jan 2025 21:52:17 +0100 Subject: [PATCH] feat(summaries): add endpoint to fetch container's summary (#415) * feat: add minimal transaction summary service * feat: add endpoint to fetch container summary * fix: failing test case * chore: add reference to PR * chore: add documentation --- .../response/transaction-summary-response.ts | 45 ++++++ .../transaction-summary-controller.ts | 89 ++++++++++ src/index.ts | 2 + src/service/transaction-summary-service.ts | 128 +++++++++++++++ .../transaction-summary-controller.ts | 132 +++++++++++++++ .../service/transaction-summary-service.ts | 152 ++++++++++++++++++ 6 files changed, 548 insertions(+) create mode 100644 src/controller/response/transaction-summary-response.ts create mode 100644 src/controller/transaction-summary-controller.ts create mode 100644 src/service/transaction-summary-service.ts create mode 100644 test/unit/controller/transaction-summary-controller.ts create mode 100644 test/unit/service/transaction-summary-service.ts diff --git a/src/controller/response/transaction-summary-response.ts b/src/controller/response/transaction-summary-response.ts new file mode 100644 index 00000000..2085307d --- /dev/null +++ b/src/controller/response/transaction-summary-response.ts @@ -0,0 +1,45 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +/** + * This is the module page of the transaction summaries. + * Not that this module has been created in very strict time constraints, + * so its implementation is very minimal. + * https://github.com/GEWIS/sudosos-backend/pull/415 + * + * @module transaction-summaries + */ + +import { BaseUserResponse } from './user-response'; +import { DineroObjectResponse } from './dinero-response'; + +/** + * @typedef {object} ContainerSummaryResponse + * @property {allOf|BaseUserResponse} user.required + * @property {allOf|DineroObjectResponse} totalInclVat.required + * @property {integer} amountOfProducts.required + * @property {integer} containerId.required + */ +export interface ContainerSummaryResponse { + user: BaseUserResponse; + totalInclVat: DineroObjectResponse; + amountOfProducts: number; + containerId: number; +} diff --git a/src/controller/transaction-summary-controller.ts b/src/controller/transaction-summary-controller.ts new file mode 100644 index 00000000..ee44bc7f --- /dev/null +++ b/src/controller/transaction-summary-controller.ts @@ -0,0 +1,89 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +/** + * This is the module page of the transaction summaries. + * Not that this module has been created in very strict time constraints, + * so its implementation is very minimal. + * https://github.com/GEWIS/sudosos-backend/pull/415 + * + * @module transaction-summaries + */ + +import { Response } from 'express'; +import log4js, { Logger } from 'log4js'; +import BaseController, { BaseControllerOptions } from './base-controller'; +import Policy from './policy'; +import { RequestWithToken } from '../middleware/token-middleware'; +import TransactionSummaryService from '../service/transaction-summary-service'; + +export default class TransactionSummaryController extends BaseController { + private logger: Logger = log4js.getLogger('TransactionSummaryController'); + + public constructor(options: BaseControllerOptions) { + super(options); + this.logger.level = process.env.LOG_LEVEL; + } + + public getPolicy(): Policy { + return { + '/container/:id(\\d+)': { + GET: { + policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Transaction', ['*']), + handler: this.getSingleContainerSummary.bind(this), + }, + }, + }; + } + + /** + * GET /transactions/summary/container/{id} + * @summary Returns a summary of all purchases within a container + * @operationId getSingleContainerSummary + * @tags transactionSummaries - Operations of the transaction summary controller + * @security JWT + * @deprecated - Hotfix for Feestcafé "De BAC" - 70s Disco Edition. Do not use for anything else. https://github.com/GEWIS/sudosos-backend/pull/415 + * @param {integer} id.path.required - The ID of the container + * @return {Array} 200 - The requested summary + * @return {string} 404 - Not found error + * @return {string} 500 - Internal server error + */ + public async getSingleContainerSummary(req: RequestWithToken, res: Response): Promise { + const { id: rawId } = req.params; + this.logger.trace('Get single container summary of container', rawId, ', by user', req.token.user); + + try { + const id = Number(rawId); + const summaries = await new TransactionSummaryService().getContainerSummary({ containerId: id }); + if (summaries.length === 0) { + // This also causes a 404 if the container exists, but no transactions have been made. + // However, this is a won't fix for now (because time) + // https://github.com/GEWIS/sudosos-backend/pull/415 + res.status(404).json('Container not found.'); + return; + } + + res.status(200).json(summaries.map((s) => TransactionSummaryService.toContainerSummaryResponse(s))); + } catch (e) { + res.status(500).send('Internal server error.'); + this.logger.error(e); + } + } +} diff --git a/src/index.ts b/src/index.ts index 6ec92d08..6fedbe8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,7 @@ import ServerSettingsStore from './server-settings/server-settings-store'; import SellerPayoutController from './controller/seller-payout-controller'; import { ISettings } from './entity/server-setting'; import ServerSettingsController from './controller/server-settings-controller'; +import TransactionSummaryController from './controller/transaction-summary-controller'; export class Application { app: express.Express; @@ -246,6 +247,7 @@ export default async function createApp(): Promise { application.app.use('/v1/productcategories', new ProductCategoryController(options).getRouter()); application.app.use('/v1/pointsofsale', new PointOfSaleController(options).getRouter()); application.app.use('/v1/transactions', new TransactionController(options).getRouter()); + application.app.use('/v1/transactions/summary', new TransactionSummaryController(options).getRouter()); application.app.use('/v1/vouchergroups', new VoucherGroupController(options).getRouter()); application.app.use('/v1/transfers', new TransferController(options).getRouter()); application.app.use('/v1/fines', new DebtorController(options).getRouter()); diff --git a/src/service/transaction-summary-service.ts b/src/service/transaction-summary-service.ts new file mode 100644 index 00000000..a61d5c1a --- /dev/null +++ b/src/service/transaction-summary-service.ts @@ -0,0 +1,128 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +/** + * This is the module page of the transaction summaries. + * Not that this module has been created in very strict time constraints, + * so its implementation is very minimal. + * https://github.com/GEWIS/sudosos-backend/pull/415 + * + * @module transaction-summaries + */ + +import { Dinero } from 'dinero.js'; +import User from '../entity/user/user'; +import WithManager from '../database/with-manager'; +import SubTransactionRow from '../entity/transactions/sub-transaction-row'; +import Transaction from '../entity/transactions/transaction'; +import SubTransaction from '../entity/transactions/sub-transaction'; +import ProductRevision from '../entity/product/product-revision'; +import { SelectQueryBuilder } from 'typeorm'; +import DineroTransformer from '../entity/transformer/dinero-transformer'; +import { ContainerSummaryResponse } from '../controller/response/transaction-summary-response'; + +interface BaseSummary { + user: User; + totalInclVat: Dinero; + amountOfProducts: number; +} + +interface ProductSummary extends BaseSummary { + productId: number; +} + +interface ContainerSummary extends BaseSummary { + containerId: number; +} + +interface PointOfSaleSummary extends BaseSummary { + pointOfSaleId: number; +} + +interface UserSummary extends BaseSummary { + user: User; + products: ProductSummary[]; + containers: ContainerSummary[]; + pointsOfSale: PointOfSaleSummary[]; +} + +interface SummaryFilters { + containerId?: number; +} + +/** + * Minimal implementation of the summary service. + * https://github.com/GEWIS/sudosos-backend/pull/415 + */ +export default class TransactionSummaryService extends WithManager { + public static toContainerSummaryResponse(containerSummary: ContainerSummary): ContainerSummaryResponse { + return { + user: { + id: containerSummary.user.id, + firstName: containerSummary.user.firstName, + nickname: containerSummary.user.nickname, + lastName: containerSummary.user.lastName, + }, + totalInclVat: containerSummary.totalInclVat.toObject(), + amountOfProducts: containerSummary.amountOfProducts, + containerId: containerSummary.containerId, + }; + } + + private getBaseQueryBuilder(): SelectQueryBuilder { + return this.manager.createQueryBuilder(User, 'user') + .innerJoinAndSelect(Transaction, 'transaction', 'transaction.fromId = user.id') + .innerJoinAndSelect(SubTransaction, 'subTransaction', 'subTransaction.transactionId = transaction.id') + // .innerJoinAndSelect(ContainerRevision, 'containerRevision', 'containerRevision.containerId = subTransaction.containerContainerId AND containerRevision.revision = subTransaction.containerRevision') + .innerJoinAndSelect(SubTransactionRow, 'subTransactionRow', 'subTransactionRow.subTransactionId = subTransaction.id') + .innerJoin(ProductRevision, 'productRevision', 'productRevision.productId = subTransactionRow.productProductId AND productRevision.revision = subTransactionRow.productRevision') + .addSelect('sum(subTransactionRow.amount * productRevision.priceInclVat) as totalValueInclVat') + .addSelect('sum(subTransactionRow.amount) as totalAmount'); + } + + private addFilters(query: SelectQueryBuilder, filters?: SummaryFilters): SelectQueryBuilder { + if (!filters) return query; + if (filters.containerId) query.where('subTransaction.containerContainerId = :containerId', { containerId: filters.containerId }); + return query; + } + + public async getContainerSummary(filters?: SummaryFilters): Promise { + const query = this.getBaseQueryBuilder() + .groupBy('user.id, subTransaction.containerContainerId'); + + const data = await this.addFilters(query, filters) + .getRawAndEntities(); + + return data.raw.map((r): ContainerSummary => { + const user = data.entities.find((u) => u.id === r.user_id); + return { + user, + totalInclVat: DineroTransformer.Instance.from(r.totalValueInclVat), + amountOfProducts: Number(r.totalAmount), + containerId: r.subTransaction_containerContainerId, + }; + }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async getSummary(filters?: SummaryFilters): Promise { + throw new Error('Not yet implemented'); + } +} diff --git a/test/unit/controller/transaction-summary-controller.ts b/test/unit/controller/transaction-summary-controller.ts new file mode 100644 index 00000000..d432eb44 --- /dev/null +++ b/test/unit/controller/transaction-summary-controller.ts @@ -0,0 +1,132 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ +import { DefaultContext, defaultBefore, finishTestDB } from '../../helpers/test-helpers'; +import TransactionSummaryController from '../../../src/controller/transaction-summary-controller'; +import { ContainerSeeder, PointOfSaleSeeder, RbacSeeder, TransactionSeeder, UserSeeder } from '../../seed'; +import Container from '../../../src/entity/container/container'; +import Transaction from '../../../src/entity/transactions/transaction'; +import { expect, request } from 'chai'; +import User, { UserType } from '../../../src/entity/user/user'; +import { ContainerSummaryResponse } from '../../../src/controller/response/transaction-summary-response'; +import { json } from 'body-parser'; +import TokenMiddleware from '../../../src/middleware/token-middleware'; + +describe('TransactionSummaryController', () => { + let ctx: DefaultContext & { + controller: TransactionSummaryController, + admin: User, + user: User, + adminToken: string, + userToken: string, + containers: Container[], + transactions: Transaction[], + }; + + before(async () => { + const d = await defaultBefore(); + + const users = await new UserSeeder().seed(); + const { containers, containerRevisions } = await new ContainerSeeder().seed(users); + const { pointOfSaleRevisions } = await new PointOfSaleSeeder().seed(users, containerRevisions); + const { transactions } = await new TransactionSeeder().seed(users, pointOfSaleRevisions); + + const all = { all: new Set(['*']) }; + const roles = await new RbacSeeder().seed([{ + name: 'Admin', + permissions: { + Transaction: { + create: all, + get: all, + update: all, + delete: all, + }, + }, + assignmentCheck: async (usr: User) => usr.type === UserType.LOCAL_ADMIN, + }]); + await d.roleManager.initialize(); + + const admin = users.find((u) => u.type === UserType.LOCAL_ADMIN); + const user = users.find((u) => u.type === UserType.LOCAL_USER); + const adminToken = await d.tokenHandler.signToken(await new RbacSeeder().getToken(admin, roles), 'nonce admin'); + const userToken = await d.tokenHandler.signToken(await new RbacSeeder().getToken(user, roles), 'nonce user'); + + const controller = new TransactionSummaryController({ specification: d.specification, roleManager: d.roleManager }); + d.app.use(json()); + d.app.use(new TokenMiddleware({ tokenHandler: d.tokenHandler, refreshFactor: 0.5 }).getMiddleware()); + d.app.use('/transactions/summary', controller.getRouter()); + + ctx = { + ...d, + controller, + admin, + user, + adminToken, + userToken, + containers, + transactions, + }; + }); + + after(async () => { + await finishTestDB(ctx.connection); + }); + + describe('GET /transactions/summary/container/:id', () => { + it('should correctly return response', async () => { + const container = ctx.containers[0]; + const res = await request(ctx.app) + .get(`/transactions/summary/container/${container.id}`) + .set('Authorization', `Bearer ${ctx.adminToken}`); + + expect(res.status).to.equal(200); + + const validation = ctx.specification.validateModel('Array', res.body, false, true); + expect(validation.valid).to.be.true; + + const seenUsers = new Set(); + const body = res.body as ContainerSummaryResponse[]; + body.forEach((summary) => { + expect(summary.containerId).to.equal(container.id); + seenUsers.add(summary.user.id); + }); + + expect(seenUsers.size).to.equal(body.length); + }); + + it('should return 404 if container does not exist', async () => { + const containerId = ctx.containers.length + 1; + const res = await request(ctx.app) + .get(`/transactions/summary/container/${containerId}`) + .set('Authorization', `Bearer ${ctx.adminToken}`); + + expect(res.status).to.equal(404); + expect(res.body).to.equal('Container not found.'); + }); + + it('should return 403 if not admin', async () => { + const container = ctx.containers[0]; + const res = await request(ctx.app) + .get(`/transactions/summary/container/${container.id}`) + .set('Authorization', `Bearer ${ctx.userToken}`); + + expect(res.status).to.equal(403); + }); + }); +}); diff --git a/test/unit/service/transaction-summary-service.ts b/test/unit/service/transaction-summary-service.ts new file mode 100644 index 00000000..e0ed7599 --- /dev/null +++ b/test/unit/service/transaction-summary-service.ts @@ -0,0 +1,152 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ +import { defaultBefore, DefaultContext, finishTestDB } from '../../helpers/test-helpers'; +import { ContainerSeeder, PointOfSaleSeeder, TransactionSeeder, UserSeeder } from '../../seed'; +import Container from '../../../src/entity/container/container'; +import User from '../../../src/entity/user/user'; +import Transaction from '../../../src/entity/transactions/transaction'; +import TransactionSummaryService from '../../../src/service/transaction-summary-service'; +import Dinero from 'dinero.js'; +import { expect } from 'chai'; + +describe('TransactionSummaryService', () => { + let ctx: DefaultContext & { + users: User[], + containers: Container[], + transactions: Transaction[], + }; + + before(async () => { + const d = await defaultBefore(); + + const users = await new UserSeeder().seed(); + const { containers, containerRevisions } = await new ContainerSeeder().seed(users); + const { pointOfSaleRevisions } = await new PointOfSaleSeeder().seed(users, containerRevisions); + const { transactions } = await new TransactionSeeder().seed(users, pointOfSaleRevisions); + + ctx = { + ...d, + users, + containers, + transactions, + }; + }); + + after(async () => { + await finishTestDB(ctx.connection); + }); + + describe('#getContainerSummary', () => { + const calculateActualValues = (user: User, containerId: number) => { + const transactions = ctx.transactions.filter((t) => t.from.id === user.id + && t.subTransactions.some((st) => st.container.containerId === containerId)); + const subTransactions = transactions.map((t) => t.subTransactions) + .flat() + .filter((subTransaction) => subTransaction?.container.containerId === containerId); + const subTransactionRows = subTransactions.map((st) => st.subTransactionRows).flat(); + + const amountOfProducts = subTransactionRows.reduce((total, str) => total + str.amount, 0); + const totalInclVat = subTransactionRows.reduce((total, str) => total.add(str.product.priceInclVat.multiply(str.amount)), Dinero()); + + return { amountOfProducts, totalInclVat }; + }; + + it('should return the summary of all user\'s purchases for each container', async () => { + const summaries = await new TransactionSummaryService().getContainerSummary(); + const seenUserIds = new Set(); + + let actualTotalValue = Dinero(); + + summaries.forEach((summary) => { + seenUserIds.add(summary.user.id); + const expectedUser = ctx.users.find((u) => u.id === summary.user.id); + expect(expectedUser).to.not.be.undefined; + expect(expectedUser.firstName).to.equal(summary.user.firstName); + expect(expectedUser.lastName).to.equal(summary.user.lastName); + + const { amountOfProducts, totalInclVat } = calculateActualValues(summary.user, summary.containerId); + expect(summary.amountOfProducts).to.equal(amountOfProducts); + expect(summary.totalInclVat.getAmount()).to.equal(totalInclVat.getAmount()); + + actualTotalValue = actualTotalValue.add(summary.totalInclVat); + }); + + const expectedTotalValue = ctx.transactions.reduce((totalTransaction, t) => { + const subTransactionValue = t.subTransactions.reduce((totalSubTransaction, st) => { + const subTransactionRowValue = st.subTransactionRows.reduce((totalSubTransactionRow, str) => { + return totalSubTransactionRow.add(str.product.priceInclVat.multiply(str.amount)); + }, Dinero()); + return totalSubTransaction.add(subTransactionRowValue); + }, Dinero()); + return totalTransaction.add(subTransactionValue); + }, Dinero()); + + // Sum of all summaries should add up to the complete sum of all transactions + expect(actualTotalValue.getAmount()).to.equal(expectedTotalValue.getAmount()); + + const missingUsers = ctx.users.filter((u) => !seenUserIds.has(u.id)); + // If an user is missing, it should be because the user has no (or incorrect) transactions + if (missingUsers.length > 0) { + missingUsers.forEach((u) => { + const transactions = ctx.transactions.filter((t) => t.from.id === u.id); + if (transactions.length > 0) { + const subTransactions = transactions.map((t) => t.subTransactions).flat(); + if (subTransactions.length > 0) { + const subTransactionRows = subTransactions.map((st) => st.subTransactionRows).flat(); + // Should have no valid transactions. Otherwise, this user was not included! + expect(subTransactionRows.length).to.equal(0); + } + } + }); + } + }); + it('should filter on container ID', async () => { + const container = ctx.containers[0]; + const summaries = await new TransactionSummaryService().getContainerSummary({ containerId: container.id }); + + let actualTotalValue = Dinero(); + + summaries.forEach((summary) => { + expect(summary.containerId).to.equal(container.id); + actualTotalValue = actualTotalValue.add(summary.totalInclVat); + }); + + const expectedTotalValue = ctx.transactions.reduce((totalTransaction, t) => { + const subTransactionValue = t.subTransactions.reduce((totalSubTransaction, st) => { + if (st.container.containerId !== container.id) return totalSubTransaction; + const subTransactionRowValue = st.subTransactionRows.reduce((totalSubTransactionRow, str) => { + return totalSubTransactionRow.add(str.product.priceInclVat.multiply(str.amount)); + }, Dinero()); + return totalSubTransaction.add(subTransactionRowValue); + }, Dinero()); + return totalTransaction.add(subTransactionValue); + }, Dinero()); + + // Sum of all summaries should add up to the complete sum of all transactions using this container + expect(actualTotalValue.getAmount()).to.equal(expectedTotalValue.getAmount()); + }); + it('should return empty array if container does not exist', async () => { + const containerId = ctx.containers.length + 1; + const summaries = await new TransactionSummaryService().getContainerSummary({ containerId }); + + expect(summaries.length).to.equal(0); + }); + }); +});